ghostbit-cli 1.0.0__tar.gz → 1.1.1__tar.gz

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