ghostbit-cli 1.0.0__tar.gz → 1.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.
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/PKG-INFO +35 -12
- ghostbit_cli-1.1.0/README.md +87 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/cli.py +360 -29
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/PKG-INFO +35 -12
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/entry_points.txt +1 -1
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/pyproject.toml +2 -2
- ghostbit_cli-1.0.0/README.md +0 -64
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/SOURCES.txt +0 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/dependency_links.txt +0 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/requires.txt +0 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/ghostbit_cli.egg-info/top_level.txt +0 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/setup.cfg +0 -0
- {ghostbit_cli-1.0.0 → ghostbit_cli-1.1.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghostbit-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/stackopshq/ghostbit
|
|
@@ -43,26 +43,43 @@ All content is encrypted **in the client** before being sent to the server. The
|
|
|
43
43
|
pip install ghostbit-cli
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
With syntax highlighting and Markdown rendering:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "ghostbit-cli[all]" # pygments + rich
|
|
50
|
+
pip install "ghostbit-cli[color]" # pygments only (syntax highlighting)
|
|
51
|
+
pip install "ghostbit-cli[markdown]" # rich only (Markdown rendering)
|
|
52
|
+
```
|
|
53
|
+
|
|
46
54
|
## Usage
|
|
47
55
|
|
|
48
56
|
```bash
|
|
49
57
|
# Paste from stdin
|
|
50
|
-
cat file.py |
|
|
58
|
+
cat file.py | gbit
|
|
51
59
|
|
|
52
60
|
# Paste a file (language auto-detected from extension)
|
|
53
|
-
|
|
61
|
+
gbit file.py
|
|
54
62
|
|
|
55
63
|
# With options
|
|
56
|
-
|
|
64
|
+
gbit file.py --lang python --burn --expires 3600
|
|
65
|
+
|
|
66
|
+
# Password-protected paste (prompted securely)
|
|
67
|
+
echo "secret" | gbit -p
|
|
57
68
|
|
|
58
|
-
#
|
|
59
|
-
|
|
69
|
+
# Or pass inline (visible in process list)
|
|
70
|
+
gbit file.py --password mysecret
|
|
71
|
+
|
|
72
|
+
# View and decrypt a paste in the terminal
|
|
73
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
74
|
+
|
|
75
|
+
# View a password-protected paste (prompts for password)
|
|
76
|
+
gbit view https://paste.example.com/abc123#~TOKEN
|
|
60
77
|
|
|
61
78
|
# Output JSON (includes full URL with decryption key)
|
|
62
|
-
cat data.json |
|
|
79
|
+
cat data.json | gbit --json
|
|
63
80
|
|
|
64
81
|
# Point to your self-hosted instance
|
|
65
|
-
|
|
82
|
+
gbit config set server https://paste.example.com
|
|
66
83
|
```
|
|
67
84
|
|
|
68
85
|
## Options
|
|
@@ -73,21 +90,27 @@ gb config set server https://paste.example.com
|
|
|
73
90
|
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
74
91
|
| `--burn` | `-b` | Delete after the first view |
|
|
75
92
|
| `--max-views` | `-m` | Delete after N views |
|
|
76
|
-
| `--password` | `-p` | Encrypt with a password |
|
|
93
|
+
| `--password` | `-p` | Encrypt with a password (prompted if no value given) |
|
|
77
94
|
| `--server` | `-s` | Override server URL for this call |
|
|
78
95
|
| `--quiet` | `-q` | Print URL only |
|
|
79
96
|
| `--json` | | Print full JSON response |
|
|
97
|
+
| `--no-history` | | Don't save to local history |
|
|
98
|
+
| `--version` | `-V` | Print version and exit |
|
|
80
99
|
|
|
81
100
|
## Configuration
|
|
82
101
|
|
|
83
102
|
```bash
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
gbit config set server https://paste.example.com
|
|
104
|
+
gbit config show
|
|
105
|
+
gbit config unset server
|
|
87
106
|
```
|
|
88
107
|
|
|
89
108
|
Config is stored at `~/.config/ghostbit.toml`.
|
|
90
109
|
|
|
110
|
+
## Security Note
|
|
111
|
+
|
|
112
|
+
The local history (`~/.local/share/ghostbit/history.jsonl`) stores full URLs **including decryption keys**. Use `--no-history` for sensitive pastes, or clear history with `gbit list --clear`.
|
|
113
|
+
|
|
91
114
|
## Self-hosting
|
|
92
115
|
|
|
93
116
|
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Ghostbit CLI
|
|
2
|
+
|
|
3
|
+
Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
|
|
4
|
+
|
|
5
|
+
All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ghostbit-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
With syntax highlighting and Markdown rendering:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install "ghostbit-cli[all]" # pygments + rich
|
|
17
|
+
pip install "ghostbit-cli[color]" # pygments only (syntax highlighting)
|
|
18
|
+
pip install "ghostbit-cli[markdown]" # rich only (Markdown rendering)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Paste from stdin
|
|
25
|
+
cat file.py | gbit
|
|
26
|
+
|
|
27
|
+
# Paste a file (language auto-detected from extension)
|
|
28
|
+
gbit file.py
|
|
29
|
+
|
|
30
|
+
# With options
|
|
31
|
+
gbit file.py --lang python --burn --expires 3600
|
|
32
|
+
|
|
33
|
+
# Password-protected paste (prompted securely)
|
|
34
|
+
echo "secret" | gbit -p
|
|
35
|
+
|
|
36
|
+
# Or pass inline (visible in process list)
|
|
37
|
+
gbit file.py --password mysecret
|
|
38
|
+
|
|
39
|
+
# View and decrypt a paste in the terminal
|
|
40
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
41
|
+
|
|
42
|
+
# View a password-protected paste (prompts for password)
|
|
43
|
+
gbit view https://paste.example.com/abc123#~TOKEN
|
|
44
|
+
|
|
45
|
+
# Output JSON (includes full URL with decryption key)
|
|
46
|
+
cat data.json | gbit --json
|
|
47
|
+
|
|
48
|
+
# Point to your self-hosted instance
|
|
49
|
+
gbit config set server https://paste.example.com
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
| Flag | Short | Description |
|
|
55
|
+
|------|-------|-------------|
|
|
56
|
+
| `--lang` | `-l` | Language hint (python, go, rust, …) |
|
|
57
|
+
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
58
|
+
| `--burn` | `-b` | Delete after the first view |
|
|
59
|
+
| `--max-views` | `-m` | Delete after N views |
|
|
60
|
+
| `--password` | `-p` | Encrypt with a password (prompted if no value given) |
|
|
61
|
+
| `--server` | `-s` | Override server URL for this call |
|
|
62
|
+
| `--quiet` | `-q` | Print URL only |
|
|
63
|
+
| `--json` | | Print full JSON response |
|
|
64
|
+
| `--no-history` | | Don't save to local history |
|
|
65
|
+
| `--version` | `-V` | Print version and exit |
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gbit config set server https://paste.example.com
|
|
71
|
+
gbit config show
|
|
72
|
+
gbit config unset server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Config is stored at `~/.config/ghostbit.toml`.
|
|
76
|
+
|
|
77
|
+
## Security Note
|
|
78
|
+
|
|
79
|
+
The local history (`~/.local/share/ghostbit/history.jsonl`) stores full URLs **including decryption keys**. Use `--no-history` for sensitive pastes, or clear history with `gbit list --clear`.
|
|
80
|
+
|
|
81
|
+
## Self-hosting
|
|
82
|
+
|
|
83
|
+
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
Ghostbit CLI — create and view pastes from the terminal.
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
|
-
cat file.py |
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
cat file.py | gbit
|
|
7
|
+
gbit file.py
|
|
8
|
+
gbit file.py --lang python --burn
|
|
9
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
10
|
+
gbit config set server https://paste.example.com
|
|
11
|
+
gbit config show
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import argparse
|
|
15
15
|
import base64
|
|
16
|
+
import getpass
|
|
16
17
|
import json
|
|
17
18
|
import os
|
|
18
19
|
import sys
|
|
@@ -21,9 +22,16 @@ import urllib.error
|
|
|
21
22
|
import urllib.request
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
24
|
-
__version__ = "1.
|
|
25
|
+
__version__ = "1.1.0"
|
|
25
26
|
_USER_AGENT = f"Ghostbit-CLI/{__version__}"
|
|
26
27
|
|
|
28
|
+
LANGUAGES = [
|
|
29
|
+
"python", "javascript", "typescript", "go", "rust", "ruby", "php",
|
|
30
|
+
"java", "c", "cpp", "csharp", "bash", "powershell", "html", "css",
|
|
31
|
+
"sql", "json", "yaml", "toml", "xml", "markdown", "dockerfile",
|
|
32
|
+
"kotlin", "swift", "lua", "r", "diff",
|
|
33
|
+
]
|
|
34
|
+
|
|
27
35
|
try:
|
|
28
36
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
29
37
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
@@ -67,7 +75,8 @@ def _key_to_fragment(key: bytes) -> str:
|
|
|
67
75
|
|
|
68
76
|
# ── Config ────────────────────────────────────────────────────────────────────
|
|
69
77
|
|
|
70
|
-
CONFIG_PATH
|
|
78
|
+
CONFIG_PATH = Path.home() / ".config" / "ghostbit.toml"
|
|
79
|
+
HISTORY_PATH = Path.home() / ".local" / "share" / "ghostbit" / "history.jsonl"
|
|
71
80
|
DEFAULT_SERVER = "http://localhost:8000"
|
|
72
81
|
|
|
73
82
|
VALID_KEYS = {"server"}
|
|
@@ -96,7 +105,7 @@ def _write_config(cfg: dict) -> None:
|
|
|
96
105
|
# ── Subcommands ───────────────────────────────────────────────────────────────
|
|
97
106
|
|
|
98
107
|
def _run_config(argv):
|
|
99
|
-
parser = argparse.ArgumentParser(prog="
|
|
108
|
+
parser = argparse.ArgumentParser(prog="gbit config", description="Manage Ghostbit CLI configuration.")
|
|
100
109
|
sub = parser.add_subparsers(dest="action")
|
|
101
110
|
|
|
102
111
|
sub.add_parser("show", help="Show current configuration.")
|
|
@@ -174,7 +183,11 @@ def cmd_paste(args):
|
|
|
174
183
|
".dockerfile": "dockerfile", ".kt": "kotlin", ".swift": "swift",
|
|
175
184
|
".lua": "lua", ".r": "r", ".diff": "diff", ".patch": "diff",
|
|
176
185
|
}
|
|
177
|
-
|
|
186
|
+
# Handle extensionless files by name (e.g. Dockerfile, Makefile)
|
|
187
|
+
name_map = {
|
|
188
|
+
"dockerfile": "dockerfile", "makefile": "makefile",
|
|
189
|
+
}
|
|
190
|
+
args.lang = ext_map.get(path.suffix.lower()) or name_map.get(path.name.lower())
|
|
178
191
|
else:
|
|
179
192
|
if sys.stdin.isatty():
|
|
180
193
|
print("Reading from stdin… (Ctrl+D to finish)", file=sys.stderr)
|
|
@@ -187,9 +200,17 @@ def cmd_paste(args):
|
|
|
187
200
|
_require_crypto()
|
|
188
201
|
|
|
189
202
|
# ── Encrypt client-side (mirrors browser e2e.js) ──
|
|
190
|
-
|
|
203
|
+
# If --password was given without a value, prompt interactively
|
|
204
|
+
password = args.password
|
|
205
|
+
if password is True or password == '':
|
|
206
|
+
password = getpass.getpass('Password: ')
|
|
207
|
+
if not password:
|
|
208
|
+
print('Error: password cannot be empty.', file=sys.stderr)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
|
|
211
|
+
if password:
|
|
191
212
|
kdf_salt = _gen_salt()
|
|
192
|
-
key = _derive_key(
|
|
213
|
+
key = _derive_key(password, kdf_salt)
|
|
193
214
|
else:
|
|
194
215
|
key = _gen_key()
|
|
195
216
|
kdf_salt = None
|
|
@@ -209,13 +230,24 @@ def cmd_paste(args):
|
|
|
209
230
|
result = _api_create(server, payload)
|
|
210
231
|
|
|
211
232
|
# Build full URL with fragment (key + delete token)
|
|
212
|
-
if
|
|
233
|
+
if password:
|
|
213
234
|
fragment = f"~{result['delete_token']}"
|
|
214
235
|
else:
|
|
215
236
|
fragment = f"{_key_to_fragment(key)}~{result['delete_token']}"
|
|
216
237
|
|
|
217
238
|
full_url = f"{result['url']}#{fragment}"
|
|
218
239
|
|
|
240
|
+
# Append to local history (best-effort, privacy-first — stays on disk only)
|
|
241
|
+
if not args.no_history:
|
|
242
|
+
_history_append({
|
|
243
|
+
"id": result["id"],
|
|
244
|
+
"url": result["url"],
|
|
245
|
+
"full_url": full_url,
|
|
246
|
+
"created_at": int(time.time()),
|
|
247
|
+
"language": args.lang,
|
|
248
|
+
"expires_at": result.get("expires_at"),
|
|
249
|
+
})
|
|
250
|
+
|
|
219
251
|
if args.json:
|
|
220
252
|
result["full_url"] = full_url
|
|
221
253
|
print(json.dumps(result, indent=2))
|
|
@@ -237,11 +269,11 @@ def cmd_paste(args):
|
|
|
237
269
|
parts.append("burn after read")
|
|
238
270
|
if result.get("max_views"):
|
|
239
271
|
parts.append(f"max {result['max_views']} views")
|
|
240
|
-
if
|
|
272
|
+
if password:
|
|
241
273
|
parts.append("password protected")
|
|
242
274
|
if parts:
|
|
243
275
|
print(" " + " · ".join(parts), file=sys.stderr)
|
|
244
|
-
if not
|
|
276
|
+
if not password:
|
|
245
277
|
print(" Share the full URL — the decryption key is in the #fragment.", file=sys.stderr)
|
|
246
278
|
|
|
247
279
|
|
|
@@ -281,7 +313,6 @@ def _print_highlighted(content: str, language: str | None) -> None:
|
|
|
281
313
|
|
|
282
314
|
|
|
283
315
|
def cmd_view(args):
|
|
284
|
-
import getpass
|
|
285
316
|
from urllib.parse import urldefrag, urlparse
|
|
286
317
|
|
|
287
318
|
url_no_frag, fragment = urldefrag(args.url)
|
|
@@ -375,10 +406,275 @@ def _api_create(server: str, payload: dict) -> dict:
|
|
|
375
406
|
sys.exit(1)
|
|
376
407
|
except urllib.error.URLError as e:
|
|
377
408
|
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
378
|
-
print(f" Tip: run `
|
|
409
|
+
print(f" Tip: run `gbit config set server <URL>` to set your server.", file=sys.stderr)
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ── Local history ────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
def _history_append(entry: dict) -> None:
|
|
416
|
+
"""Append one entry to the local history file (JSONL, one JSON object per line)."""
|
|
417
|
+
try:
|
|
418
|
+
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
with HISTORY_PATH.open("a", encoding="utf-8") as f:
|
|
420
|
+
f.write(json.dumps(entry) + "\n")
|
|
421
|
+
except Exception:
|
|
422
|
+
pass # history is best-effort, never block a paste creation
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _history_load() -> list[dict]:
|
|
426
|
+
if not HISTORY_PATH.exists():
|
|
427
|
+
return []
|
|
428
|
+
entries = []
|
|
429
|
+
for line in HISTORY_PATH.read_text(encoding="utf-8").splitlines():
|
|
430
|
+
line = line.strip()
|
|
431
|
+
if line:
|
|
432
|
+
try:
|
|
433
|
+
entries.append(json.loads(line))
|
|
434
|
+
except json.JSONDecodeError:
|
|
435
|
+
pass
|
|
436
|
+
return entries
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def cmd_delete(url: str) -> None:
|
|
440
|
+
from urllib.parse import urldefrag, urlparse
|
|
441
|
+
|
|
442
|
+
url_no_frag, fragment = urldefrag(url)
|
|
443
|
+
parsed = urlparse(url_no_frag)
|
|
444
|
+
server = f"{parsed.scheme}://{parsed.netloc}"
|
|
445
|
+
paste_id = parsed.path.strip("/")
|
|
446
|
+
|
|
447
|
+
if not paste_id or not parsed.scheme:
|
|
448
|
+
print("Error: invalid URL.", file=sys.stderr)
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
|
|
451
|
+
# Fragment: KEY~DELETE_TOKEN or ~DELETE_TOKEN
|
|
452
|
+
delete_token = fragment.partition("~")[2]
|
|
453
|
+
if not delete_token:
|
|
454
|
+
print("Error: delete token missing from URL fragment (expected KEY~TOKEN or ~TOKEN).", file=sys.stderr)
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
|
|
457
|
+
api_url = f"{server}/api/v1/pastes/{paste_id}"
|
|
458
|
+
req = urllib.request.Request(
|
|
459
|
+
api_url,
|
|
460
|
+
headers={"User-Agent": _USER_AGENT, "X-Delete-Token": delete_token},
|
|
461
|
+
method="DELETE",
|
|
462
|
+
)
|
|
463
|
+
try:
|
|
464
|
+
with urllib.request.urlopen(req, timeout=15):
|
|
465
|
+
pass
|
|
466
|
+
print(f"Deleted {paste_id}.")
|
|
467
|
+
except urllib.error.HTTPError as e:
|
|
468
|
+
if e.code == 403:
|
|
469
|
+
print("Error: invalid delete token.", file=sys.stderr)
|
|
470
|
+
elif e.code == 404:
|
|
471
|
+
print("Error: paste not found (already deleted or expired).", file=sys.stderr)
|
|
472
|
+
else:
|
|
473
|
+
print(f"Error {e.code}.", file=sys.stderr)
|
|
474
|
+
sys.exit(1)
|
|
475
|
+
except urllib.error.URLError as e:
|
|
476
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
379
477
|
sys.exit(1)
|
|
380
478
|
|
|
381
479
|
|
|
480
|
+
def cmd_list(clear: bool = False) -> None:
|
|
481
|
+
if clear:
|
|
482
|
+
if HISTORY_PATH.exists():
|
|
483
|
+
HISTORY_PATH.unlink()
|
|
484
|
+
print("History cleared.")
|
|
485
|
+
else:
|
|
486
|
+
print("No history file found.")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
entries = _history_load()
|
|
490
|
+
if not entries:
|
|
491
|
+
print("No pastes in local history.", file=sys.stderr)
|
|
492
|
+
print(f" History file: {HISTORY_PATH}", file=sys.stderr)
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
now = int(time.time())
|
|
496
|
+
|
|
497
|
+
def _age(ts: int) -> str:
|
|
498
|
+
delta = now - ts
|
|
499
|
+
if delta < 120: return "just now"
|
|
500
|
+
if delta < 3600: return f"{delta // 60}m ago"
|
|
501
|
+
if delta < 86400: return f"{delta // 3600}h ago"
|
|
502
|
+
return f"{delta // 86400}d ago"
|
|
503
|
+
|
|
504
|
+
def _expiry(expires_at) -> str:
|
|
505
|
+
if not expires_at:
|
|
506
|
+
return "never"
|
|
507
|
+
delta = expires_at - now
|
|
508
|
+
if delta <= 0: return "expired"
|
|
509
|
+
if delta < 3600: return f"in {delta // 60}m"
|
|
510
|
+
if delta < 86400: return f"in {delta // 3600}h"
|
|
511
|
+
return f"in {delta // 86400}d"
|
|
512
|
+
|
|
513
|
+
print(f"{'ID':<12} {'Lang':<14} {'Created':<12} {'Expires':<10} URL")
|
|
514
|
+
print("-" * 80)
|
|
515
|
+
for e in reversed(entries):
|
|
516
|
+
row_id = e.get("id", "?")[:10]
|
|
517
|
+
lang = (e.get("language") or "plain")[:12]
|
|
518
|
+
created = _age(e.get("created_at", 0))
|
|
519
|
+
expires = _expiry(e.get("expires_at"))
|
|
520
|
+
full_url = e.get("full_url", e.get("url", ""))
|
|
521
|
+
print(f"{row_id:<12} {lang:<14} {created:<12} {expires:<10} {full_url}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ── Shell completion ──────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
_COMPLETION_BASH = r"""
|
|
527
|
+
# Ghostbit bash completion
|
|
528
|
+
# Usage: eval "$(gbit completion bash)" or add to ~/.bashrc
|
|
529
|
+
|
|
530
|
+
_gb_completion() {
|
|
531
|
+
local cur prev words cword
|
|
532
|
+
_init_completion 2>/dev/null || {
|
|
533
|
+
COMPREPLY=()
|
|
534
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
535
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
536
|
+
words=("${COMP_WORDS[@]}")
|
|
537
|
+
cword=$COMP_CWORD
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
local langs="LANGS_PLACEHOLDER"
|
|
541
|
+
local main_opts="--lang --expires --burn --max-views --password --server --quiet --json -l -e -b -m -p -s -q"
|
|
542
|
+
|
|
543
|
+
# Option argument completions
|
|
544
|
+
case "$prev" in
|
|
545
|
+
--lang|-l) COMPREPLY=($(compgen -W "$langs" -- "$cur")); return ;;
|
|
546
|
+
--server|-s) COMPREPLY=($(compgen -W "http:// https://" -- "$cur")); return ;;
|
|
547
|
+
--expires|-e|--max-views|-m|--password|-p) return ;;
|
|
548
|
+
esac
|
|
549
|
+
|
|
550
|
+
local subcmd="${words[1]}"
|
|
551
|
+
|
|
552
|
+
case "$subcmd" in
|
|
553
|
+
config)
|
|
554
|
+
case "$cword" in
|
|
555
|
+
2) COMPREPLY=($(compgen -W "show set unset" -- "$cur")) ;;
|
|
556
|
+
3) [[ "${words[2]}" == "set" || "${words[2]}" == "unset" ]] \
|
|
557
|
+
&& COMPREPLY=($(compgen -W "server" -- "$cur")) ;;
|
|
558
|
+
esac
|
|
559
|
+
return ;;
|
|
560
|
+
completion)
|
|
561
|
+
COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
|
|
562
|
+
return ;;
|
|
563
|
+
view) return ;;
|
|
564
|
+
esac
|
|
565
|
+
|
|
566
|
+
if [[ $cword -eq 1 ]]; then
|
|
567
|
+
COMPREPLY=($(compgen -W "config view delete list completion $main_opts" -- "$cur"))
|
|
568
|
+
COMPREPLY+=($(compgen -f -- "$cur"))
|
|
569
|
+
else
|
|
570
|
+
COMPREPLY=($(compgen -W "$main_opts" -- "$cur"))
|
|
571
|
+
COMPREPLY+=($(compgen -f -- "$cur"))
|
|
572
|
+
fi
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
complete -o filenames -F _gb_completion gbit
|
|
576
|
+
complete -o filenames -F _gb_completion ghostbit
|
|
577
|
+
"""
|
|
578
|
+
|
|
579
|
+
_COMPLETION_ZSH = r"""
|
|
580
|
+
#compdef gbit ghostbit
|
|
581
|
+
# Ghostbit zsh completion
|
|
582
|
+
# Usage: eval "$(gbit completion zsh)" or add to ~/.zshrc
|
|
583
|
+
|
|
584
|
+
_gb() {
|
|
585
|
+
local langs=(LANGS_PLACEHOLDER)
|
|
586
|
+
local main_opts=(
|
|
587
|
+
'(-l --lang)'{-l,--lang}'[language hint]:language:('"${langs[*]}"')'
|
|
588
|
+
'(-e --expires)'{-e,--expires}'[TTL in seconds]:seconds:'
|
|
589
|
+
'(-b --burn)'{-b,--burn}'[delete after first view]'
|
|
590
|
+
'(-m --max-views)'{-m,--max-views}'[delete after N views]:count:'
|
|
591
|
+
'(-p --password)'{-p,--password}'[encrypt with password]:password:'
|
|
592
|
+
'(-s --server)'{-s,--server}'[server URL]:url:'
|
|
593
|
+
'(-q --quiet)'{-q,--quiet}'[print URL only]'
|
|
594
|
+
'--json[print full JSON response]'
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
local state
|
|
598
|
+
_arguments -C \
|
|
599
|
+
'1: :->first' \
|
|
600
|
+
'*: :->rest' && return 0
|
|
601
|
+
|
|
602
|
+
case $state in
|
|
603
|
+
first)
|
|
604
|
+
_alternative \
|
|
605
|
+
'subcommands:subcommand:((config\:"manage config" view\:"view a paste" delete\:"delete a paste" list\:"list local history" completion\:"shell completion"))' \
|
|
606
|
+
"options: :_arguments ${main_opts[*]}" \
|
|
607
|
+
'files:file:_files'
|
|
608
|
+
;;
|
|
609
|
+
rest)
|
|
610
|
+
case $words[2] in
|
|
611
|
+
config)
|
|
612
|
+
case $CURRENT in
|
|
613
|
+
3) _values 'action' show set unset ;;
|
|
614
|
+
4) [[ $words[3] == (set|unset) ]] && _values 'key' server ;;
|
|
615
|
+
esac ;;
|
|
616
|
+
view) _nothing ;;
|
|
617
|
+
completion) _values 'shell' bash zsh fish ;;
|
|
618
|
+
*) _arguments "${main_opts[@]}" && _files ;;
|
|
619
|
+
esac
|
|
620
|
+
;;
|
|
621
|
+
esac
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
_gb
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
_COMPLETION_FISH = """
|
|
628
|
+
# Ghostbit fish completion
|
|
629
|
+
# Usage: gbit completion fish | source or save to ~/.config/fish/completions/gbit.fish
|
|
630
|
+
|
|
631
|
+
set -l langs LANGS_PLACEHOLDER
|
|
632
|
+
|
|
633
|
+
# Disable file completion when a subcommand is active and not needed
|
|
634
|
+
function __gb_no_subcommand
|
|
635
|
+
not __fish_seen_subcommand_from config view delete list completion
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Subcommands
|
|
639
|
+
complete -c gbit -f -n '__gb_no_subcommand' -a config -d 'Manage configuration'
|
|
640
|
+
complete -c gbit -f -n '__gb_no_subcommand' -a view -d 'View and decrypt a paste'
|
|
641
|
+
complete -c gbit -f -n '__gb_no_subcommand' -a delete -d 'Delete a paste'
|
|
642
|
+
complete -c gbit -f -n '__gb_no_subcommand' -a list -d 'List local paste history'
|
|
643
|
+
complete -c gbit -f -n '__gb_no_subcommand' -a completion -d 'Output shell completion script'
|
|
644
|
+
|
|
645
|
+
# config actions
|
|
646
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from config' -a show -d 'Show current config'
|
|
647
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from config' -a set -d 'Set a value'
|
|
648
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from config' -a unset -d 'Remove a value'
|
|
649
|
+
|
|
650
|
+
# completion shells
|
|
651
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from completion' -a bash -d 'Bash completion'
|
|
652
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from completion' -a zsh -d 'Zsh completion'
|
|
653
|
+
complete -c gbit -f -n '__fish_seen_subcommand_from completion' -a fish -d 'Fish completion'
|
|
654
|
+
|
|
655
|
+
# Main options
|
|
656
|
+
complete -c gbit -n '__gb_no_subcommand' -s l -l lang -d 'Language hint' -a "$langs"
|
|
657
|
+
complete -c gbit -n '__gb_no_subcommand' -s e -l expires -d 'TTL in seconds'
|
|
658
|
+
complete -c gbit -n '__gb_no_subcommand' -s b -l burn -d 'Delete after first view'
|
|
659
|
+
complete -c gbit -n '__gb_no_subcommand' -s m -l max-views -d 'Delete after N views'
|
|
660
|
+
complete -c gbit -n '__gb_no_subcommand' -s p -l password -d 'Encrypt with password'
|
|
661
|
+
complete -c gbit -n '__gb_no_subcommand' -s s -l server -d 'Server URL'
|
|
662
|
+
complete -c gbit -n '__gb_no_subcommand' -s q -l quiet -d 'Print URL only'
|
|
663
|
+
complete -c gbit -n '__gb_no_subcommand' -l json -d 'Print full JSON response'
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def cmd_completion(shell: str) -> None:
|
|
668
|
+
langs_str = " ".join(LANGUAGES)
|
|
669
|
+
templates = {
|
|
670
|
+
"bash": _COMPLETION_BASH,
|
|
671
|
+
"zsh": _COMPLETION_ZSH,
|
|
672
|
+
"fish": _COMPLETION_FISH,
|
|
673
|
+
}
|
|
674
|
+
script = templates[shell].replace("LANGS_PLACEHOLDER", langs_str)
|
|
675
|
+
print(script.strip())
|
|
676
|
+
|
|
677
|
+
|
|
382
678
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
383
679
|
|
|
384
680
|
def main():
|
|
@@ -387,12 +683,35 @@ def main():
|
|
|
387
683
|
_run_config(sys.argv[2:])
|
|
388
684
|
return
|
|
389
685
|
|
|
686
|
+
# Route to delete subcommand
|
|
687
|
+
if len(sys.argv) > 1 and sys.argv[1] == "delete":
|
|
688
|
+
if len(sys.argv) < 3:
|
|
689
|
+
print("Usage: gbit delete <url>", file=sys.stderr)
|
|
690
|
+
sys.exit(1)
|
|
691
|
+
cmd_delete(sys.argv[2])
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
# Route to list subcommand
|
|
695
|
+
if len(sys.argv) > 1 and sys.argv[1] == "list":
|
|
696
|
+
clear = "--clear" in sys.argv
|
|
697
|
+
cmd_list(clear=clear)
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
# Route to completion subcommand
|
|
701
|
+
if len(sys.argv) > 1 and sys.argv[1] == "completion":
|
|
702
|
+
shells = ["bash", "zsh", "fish"]
|
|
703
|
+
if len(sys.argv) < 3 or sys.argv[2] not in shells:
|
|
704
|
+
print(f"Usage: gbit completion [{'|'.join(shells)}]", file=sys.stderr)
|
|
705
|
+
sys.exit(1)
|
|
706
|
+
cmd_completion(sys.argv[2])
|
|
707
|
+
return
|
|
708
|
+
|
|
390
709
|
# Route to view subcommand
|
|
391
710
|
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
|
392
711
|
if len(sys.argv) < 3:
|
|
393
|
-
print("Usage:
|
|
712
|
+
print("Usage: gbit view <url>", file=sys.stderr)
|
|
394
713
|
sys.exit(1)
|
|
395
|
-
view_parser = argparse.ArgumentParser(prog="
|
|
714
|
+
view_parser = argparse.ArgumentParser(prog="gbit view")
|
|
396
715
|
view_parser.add_argument("url", help="Full paste URL (including #fragment).")
|
|
397
716
|
cmd_view(view_parser.parse_args(sys.argv[2:]))
|
|
398
717
|
return
|
|
@@ -401,24 +720,24 @@ def main():
|
|
|
401
720
|
current_server = cfg.get("server", DEFAULT_SERVER)
|
|
402
721
|
|
|
403
722
|
parser = argparse.ArgumentParser(
|
|
404
|
-
prog="
|
|
723
|
+
prog="gbit",
|
|
405
724
|
description="Ghostbit CLI — create encrypted pastes from the terminal.",
|
|
406
725
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
407
726
|
epilog=f"""
|
|
408
727
|
examples:
|
|
409
|
-
cat main.py |
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
728
|
+
cat main.py | gbit
|
|
729
|
+
gbit main.py
|
|
730
|
+
gbit main.py --lang python --burn
|
|
731
|
+
gbit main.py --expires 3600 --password secret
|
|
732
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
733
|
+
gbit config set server https://paste.example.com
|
|
734
|
+
gbit config show
|
|
416
735
|
|
|
417
736
|
current server: {current_server}
|
|
418
737
|
""",
|
|
419
738
|
)
|
|
420
739
|
|
|
421
|
-
# ──
|
|
740
|
+
# ── gbit [file] ──
|
|
422
741
|
parser.add_argument(
|
|
423
742
|
"file",
|
|
424
743
|
nargs="?",
|
|
@@ -456,9 +775,11 @@ current server: {current_server}
|
|
|
456
775
|
)
|
|
457
776
|
parser.add_argument(
|
|
458
777
|
"--password", "-p",
|
|
778
|
+
nargs="?",
|
|
779
|
+
const=True,
|
|
459
780
|
default=None,
|
|
460
781
|
metavar="PASS",
|
|
461
|
-
help="Encrypt with a password.",
|
|
782
|
+
help="Encrypt with a password. Omit value to be prompted securely.",
|
|
462
783
|
)
|
|
463
784
|
parser.add_argument(
|
|
464
785
|
"--quiet", "-q",
|
|
@@ -470,6 +791,16 @@ current server: {current_server}
|
|
|
470
791
|
action="store_true",
|
|
471
792
|
help="Print the full JSON response.",
|
|
472
793
|
)
|
|
794
|
+
parser.add_argument(
|
|
795
|
+
"--no-history",
|
|
796
|
+
action="store_true",
|
|
797
|
+
help="Don't save this paste to local history.",
|
|
798
|
+
)
|
|
799
|
+
parser.add_argument(
|
|
800
|
+
"--version", "-V",
|
|
801
|
+
action="version",
|
|
802
|
+
version=f"%(prog)s {__version__}",
|
|
803
|
+
)
|
|
473
804
|
|
|
474
805
|
args = parser.parse_args()
|
|
475
806
|
cmd_paste(args)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghostbit-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/stackopshq/ghostbit
|
|
@@ -43,26 +43,43 @@ All content is encrypted **in the client** before being sent to the server. The
|
|
|
43
43
|
pip install ghostbit-cli
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
With syntax highlighting and Markdown rendering:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "ghostbit-cli[all]" # pygments + rich
|
|
50
|
+
pip install "ghostbit-cli[color]" # pygments only (syntax highlighting)
|
|
51
|
+
pip install "ghostbit-cli[markdown]" # rich only (Markdown rendering)
|
|
52
|
+
```
|
|
53
|
+
|
|
46
54
|
## Usage
|
|
47
55
|
|
|
48
56
|
```bash
|
|
49
57
|
# Paste from stdin
|
|
50
|
-
cat file.py |
|
|
58
|
+
cat file.py | gbit
|
|
51
59
|
|
|
52
60
|
# Paste a file (language auto-detected from extension)
|
|
53
|
-
|
|
61
|
+
gbit file.py
|
|
54
62
|
|
|
55
63
|
# With options
|
|
56
|
-
|
|
64
|
+
gbit file.py --lang python --burn --expires 3600
|
|
65
|
+
|
|
66
|
+
# Password-protected paste (prompted securely)
|
|
67
|
+
echo "secret" | gbit -p
|
|
57
68
|
|
|
58
|
-
#
|
|
59
|
-
|
|
69
|
+
# Or pass inline (visible in process list)
|
|
70
|
+
gbit file.py --password mysecret
|
|
71
|
+
|
|
72
|
+
# View and decrypt a paste in the terminal
|
|
73
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
74
|
+
|
|
75
|
+
# View a password-protected paste (prompts for password)
|
|
76
|
+
gbit view https://paste.example.com/abc123#~TOKEN
|
|
60
77
|
|
|
61
78
|
# Output JSON (includes full URL with decryption key)
|
|
62
|
-
cat data.json |
|
|
79
|
+
cat data.json | gbit --json
|
|
63
80
|
|
|
64
81
|
# Point to your self-hosted instance
|
|
65
|
-
|
|
82
|
+
gbit config set server https://paste.example.com
|
|
66
83
|
```
|
|
67
84
|
|
|
68
85
|
## Options
|
|
@@ -73,21 +90,27 @@ gb config set server https://paste.example.com
|
|
|
73
90
|
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
74
91
|
| `--burn` | `-b` | Delete after the first view |
|
|
75
92
|
| `--max-views` | `-m` | Delete after N views |
|
|
76
|
-
| `--password` | `-p` | Encrypt with a password |
|
|
93
|
+
| `--password` | `-p` | Encrypt with a password (prompted if no value given) |
|
|
77
94
|
| `--server` | `-s` | Override server URL for this call |
|
|
78
95
|
| `--quiet` | `-q` | Print URL only |
|
|
79
96
|
| `--json` | | Print full JSON response |
|
|
97
|
+
| `--no-history` | | Don't save to local history |
|
|
98
|
+
| `--version` | `-V` | Print version and exit |
|
|
80
99
|
|
|
81
100
|
## Configuration
|
|
82
101
|
|
|
83
102
|
```bash
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
gbit config set server https://paste.example.com
|
|
104
|
+
gbit config show
|
|
105
|
+
gbit config unset server
|
|
87
106
|
```
|
|
88
107
|
|
|
89
108
|
Config is stored at `~/.config/ghostbit.toml`.
|
|
90
109
|
|
|
110
|
+
## Security Note
|
|
111
|
+
|
|
112
|
+
The local history (`~/.local/share/ghostbit/history.jsonl`) stores full URLs **including decryption keys**. Use `--no-history` for sensitive pastes, or clear history with `gbit list --clear`.
|
|
113
|
+
|
|
91
114
|
## Self-hosting
|
|
92
115
|
|
|
93
116
|
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostbit-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Ghostbit CLI — create end-to-end encrypted pastes from the terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -40,7 +40,7 @@ Repository = "https://github.com/stackopshq/ghostbit"
|
|
|
40
40
|
"Bug Tracker" = "https://github.com/stackopshq/ghostbit/issues"
|
|
41
41
|
|
|
42
42
|
[project.scripts]
|
|
43
|
-
|
|
43
|
+
gbit = "cli:main"
|
|
44
44
|
ghostbit = "cli:main"
|
|
45
45
|
|
|
46
46
|
[tool.setuptools]
|
ghostbit_cli-1.0.0/README.md
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# Ghostbit CLI
|
|
2
|
-
|
|
3
|
-
Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
|
|
4
|
-
|
|
5
|
-
All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
pip install ghostbit-cli
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Usage
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
# Paste from stdin
|
|
17
|
-
cat file.py | gb
|
|
18
|
-
|
|
19
|
-
# Paste a file (language auto-detected from extension)
|
|
20
|
-
gb file.py
|
|
21
|
-
|
|
22
|
-
# With options
|
|
23
|
-
gb file.py --lang python --burn --expires 3600
|
|
24
|
-
|
|
25
|
-
# Password-protected paste
|
|
26
|
-
echo "secret" | gb --password mysecret
|
|
27
|
-
|
|
28
|
-
# Output JSON (includes full URL with decryption key)
|
|
29
|
-
cat data.json | gb --json
|
|
30
|
-
|
|
31
|
-
# Point to your self-hosted instance
|
|
32
|
-
gb config set server https://paste.example.com
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Options
|
|
36
|
-
|
|
37
|
-
| Flag | Short | Description |
|
|
38
|
-
|------|-------|-------------|
|
|
39
|
-
| `--lang` | `-l` | Language hint (python, go, rust, …) |
|
|
40
|
-
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
41
|
-
| `--burn` | `-b` | Delete after the first view |
|
|
42
|
-
| `--max-views` | `-m` | Delete after N views |
|
|
43
|
-
| `--password` | `-p` | Encrypt with a password |
|
|
44
|
-
| `--server` | `-s` | Override server URL for this call |
|
|
45
|
-
| `--quiet` | `-q` | Print URL only |
|
|
46
|
-
| `--json` | | Print full JSON response |
|
|
47
|
-
|
|
48
|
-
## Configuration
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
gb config set server https://paste.example.com
|
|
52
|
-
gb config show
|
|
53
|
-
gb config unset server
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Config is stored at `~/.config/ghostbit.toml`.
|
|
57
|
-
|
|
58
|
-
## Self-hosting
|
|
59
|
-
|
|
60
|
-
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
61
|
-
|
|
62
|
-
## License
|
|
63
|
-
|
|
64
|
-
MIT
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|