obsideo-cli 0.3.1__tar.gz → 0.5.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.
Files changed (25) hide show
  1. obsideo_cli-0.5.0/LICENSE +21 -0
  2. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/PKG-INFO +9 -7
  3. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/README.md +5 -5
  4. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/cli.py +258 -38
  5. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/PKG-INFO +9 -7
  6. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/SOURCES.txt +1 -0
  7. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/config.py +5 -1
  8. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/login.py +9 -3
  9. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/storage.py +373 -290
  10. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/pyproject.toml +2 -2
  11. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/tests/test_cli.py +20 -2
  12. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/tests/test_core.py +91 -0
  13. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/__init__.py +0 -0
  14. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/__main__.py +0 -0
  15. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/manifest.py +0 -0
  16. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/sync.py +0 -0
  17. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  18. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
  19. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/requires.txt +0 -0
  20. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/top_level.txt +0 -0
  21. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/__init__.py +0 -0
  22. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/crypto.py +0 -0
  23. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/identity.py +0 -0
  24. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/names.py +0 -0
  25. {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/setup.cfg +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Regan Milne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.3.1
4
- Summary: Obsideo Cloud - encrypted storage we can't read, for people and AI agents. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
3
+ Version: 0.5.0
4
+ Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
7
7
  Project-URL: Documentation, https://obsideo.io/docs/
@@ -9,20 +9,22 @@ Project-URL: Agent integration (AGENTS.md), https://obsideo.io/agents.md
9
9
  Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encryption,object storage,free tier,cli,sync
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
+ License-File: LICENSE
12
13
  Requires-Dist: boto3>=1.28
13
14
  Requires-Dist: cryptography>=41.0
14
15
  Requires-Dist: certifi
16
+ Dynamic: license-file
15
17
 
16
18
  # obsideo-cli
17
19
 
18
- **Encrypted storage we can't read.** Save, browse, and sync whatever you want
20
+ **Client-side encrypted storage: your content is encrypted on your machine before upload, so the platform cannot read it.** File and folder names are encrypted too, by default (AES-SIV, keyed off your data key), so the platform sees opaque tokens rather than your filenames. Save, browse, and sync whatever you want
19
21
  from your terminal. Files are encrypted on your machine before they leave, so
20
22
  Obsideo's gateway, coordinator, and storage providers only ever see ciphertext.
21
- Your data lands on three independent providers (RF=3).
23
+ Your data lands on three providers (RF=3).
22
24
 
23
25
  ```
24
26
  pip install obsideo-cli
25
- obsideo login # email -> 3 GB free
27
+ obsideo login # email -> 12 GB free
26
28
  obsideo # open the shell
27
29
  ```
28
30
 
@@ -33,7 +35,7 @@ $ obsideo login
33
35
  Enter your email: you@example.com
34
36
  Check your email for a verification code.
35
37
  Enter verification code: 482913
36
- You're all set. 3 GB free.
38
+ You're all set. 12 GB free.
37
39
  ```
38
40
 
39
41
  Login is handled by Obsideo's signup service at **`signup.obsideo.io`**: it emails
@@ -59,7 +61,7 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
59
61
 
60
62
  | Command | Description |
61
63
  |---|---|
62
- | `obsideo login` | Sign up / log in with your email (3 GB free) |
64
+ | `obsideo login` | Sign up / log in with your email (12 GB free) |
63
65
  | `ls [path]` | List files and folders |
64
66
  | `cd <path>` / `pwd` | Move around / show location |
65
67
  | `put <local> [name]` | Encrypt + upload a file, or a whole folder (recursive). `--no-encrypt` to store as-is |
@@ -1,13 +1,13 @@
1
1
  # obsideo-cli
2
2
 
3
- **Encrypted storage we can't read.** Save, browse, and sync whatever you want
3
+ **Client-side encrypted storage: your content is encrypted on your machine before upload, so the platform cannot read it.** File and folder names are encrypted too, by default (AES-SIV, keyed off your data key), so the platform sees opaque tokens rather than your filenames. Save, browse, and sync whatever you want
4
4
  from your terminal. Files are encrypted on your machine before they leave, so
5
5
  Obsideo's gateway, coordinator, and storage providers only ever see ciphertext.
6
- Your data lands on three independent providers (RF=3).
6
+ Your data lands on three providers (RF=3).
7
7
 
8
8
  ```
9
9
  pip install obsideo-cli
10
- obsideo login # email -> 3 GB free
10
+ obsideo login # email -> 12 GB free
11
11
  obsideo # open the shell
12
12
  ```
13
13
 
@@ -18,7 +18,7 @@ $ obsideo login
18
18
  Enter your email: you@example.com
19
19
  Check your email for a verification code.
20
20
  Enter verification code: 482913
21
- You're all set. 3 GB free.
21
+ You're all set. 12 GB free.
22
22
  ```
23
23
 
24
24
  Login is handled by Obsideo's signup service at **`signup.obsideo.io`**: it emails
@@ -44,7 +44,7 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
44
44
 
45
45
  | Command | Description |
46
46
  |---|---|
47
- | `obsideo login` | Sign up / log in with your email (3 GB free) |
47
+ | `obsideo login` | Sign up / log in with your email (12 GB free) |
48
48
  | `ls [path]` | List files and folders |
49
49
  | `cd <path>` / `pwd` | Move around / show location |
50
50
  | `put <local> [name]` | Encrypt + upload a file, or a whole folder (recursive). `--no-encrypt` to store as-is |
@@ -3,7 +3,7 @@
3
3
  Save, browse, and sync whatever you want - encrypted on your machine before it
4
4
  leaves, so Obsideo can't read it. An interactive shell plus one-shot commands.
5
5
 
6
- obsideo login sign up / log in (email -> 3 GB free)
6
+ obsideo login sign up / log in (email -> 12 GB free)
7
7
  obsideo start the interactive shell
8
8
  obsideo ls / put / get ... run a single command
9
9
  """
@@ -145,6 +145,35 @@ def show_banner() -> None:
145
145
  print(f"\033[36m{_BANNER}\033[0m", file=sys.stderr)
146
146
 
147
147
 
148
+ # One-shots that print information and then leave the user at their system
149
+ # prompt with nothing on screen saying what to type next. Found in first-run
150
+ # testing: `obsideo account` printed a status block, exited, and the next thing
151
+ # typed went to cmd.exe. Commands that act on files (put/get/ls) don't need it —
152
+ # whoever ran them already knows what they're doing.
153
+ _STRANDING_COMMANDS = {"account", "about", "faq", "messages", "refer", "config", "info"}
154
+
155
+
156
+ def show_next_steps(command: str) -> None:
157
+ """One next-step line to stderr after an informational one-shot. TTY-gated,
158
+ so it never reaches an agent's stdout or a pipe."""
159
+ if not _chrome_enabled() or command.lower() not in _STRANDING_COMMANDS:
160
+ return
161
+ print("\033[2mNext:\033[0m obsideo ls · obsideo put <file> · "
162
+ "\033[36mobsideo\033[0m for the interactive shell", file=sys.stderr)
163
+
164
+
165
+ def _announce_propagation_wait() -> None:
166
+ """Said once, when a brand-new account's first call reaches the gateway
167
+ before its credentials have. Always shown, TTY or not: a script that appears
168
+ to hang for a minute needs a reason on stderr just as much as a person does."""
169
+ print(" New credentials are still reaching the storage gateway "
170
+ "(this takes up to about half a minute on a new account). Retrying...",
171
+ file=sys.stderr)
172
+
173
+
174
+ storage.propagation_notifier = _announce_propagation_wait
175
+
176
+
148
177
  def _usage_bar(pct: float, cells: int = 10) -> str:
149
178
  filled = min(cells, max(0, round(pct * cells)))
150
179
  return "#" * filled + "-" * (cells - filled)
@@ -290,35 +319,170 @@ def run_admin(argv: list) -> int:
290
319
  return 0
291
320
 
292
321
 
293
- def run_login(url: str | None = None) -> bool:
294
- """Interactive email-OTP login. Returns True on success."""
322
+ LOGIN_USAGE = """\
323
+ Usage: obsideo login [options]
324
+
325
+ With no options, prompts for everything (the human path).
326
+
327
+ Options:
328
+ --email <address> account email; skips the email prompt
329
+ --code <123456> the 6-digit code from that inbox; skips the code prompt
330
+ --source <label> attribution recorded on the account (e.g. your tool name)
331
+ --referral <code> a friend's referral code, +1 GB
332
+ --json print a machine-readable receipt instead of prose
333
+ -h, --help show this
334
+
335
+ Driving it without a terminal (two calls, because the code arrives by email):
336
+ obsideo login --email you@example.com --source my-agent --json
337
+ obsideo login --email you@example.com --code 123456 --json
338
+
339
+ With no terminal and a missing required value, this exits with an error naming
340
+ the flag rather than blocking on a prompt nobody can answer."""
341
+
342
+ _LOGIN_FLAGS = {"--email": "email", "--code": "code", "--source": "source",
343
+ "--referral": "referral"}
344
+
345
+
346
+ def _parse_login_args(args: list[str]) -> tuple[dict, str | None]:
347
+ """Tiny hand-rolled parser. Returns (kwargs, error)."""
348
+ opts: dict = {}
349
+ i = 0
350
+ while i < len(args):
351
+ a = args[i]
352
+ if a in ("-h", "--help"):
353
+ opts["help"] = True
354
+ i += 1
355
+ elif a == "--json":
356
+ opts["json_out"] = True
357
+ i += 1
358
+ elif a in _LOGIN_FLAGS:
359
+ if i + 1 >= len(args) or args[i + 1].startswith("--"):
360
+ return {}, f"{a} needs a value."
361
+ opts[_LOGIN_FLAGS[a]] = args[i + 1]
362
+ i += 2
363
+ else:
364
+ return {}, f"Unknown option for login: {a}"
365
+ return opts, None
366
+
367
+
368
+ class NonInteractive(Exception):
369
+ """A required value was missing and there is no terminal to ask on."""
370
+
371
+
372
+ def _ask(prompt: str, provided: str | None, flag: str, *, required: bool = True) -> str:
373
+ """Return a supplied value, else prompt for it, else fail loudly.
374
+
375
+ An agent or CI job running `obsideo login` used to hit a bare input() and
376
+ either hang or die with a raw EOFError traceback. Failing with a message
377
+ naming the flag is always better than either.
378
+
379
+ isatty() is checked first as a fast path but is NOT trusted on its own:
380
+ on Windows shells it can report a terminal when stdin is redirected. The
381
+ EOFError catch is what actually makes this reliable everywhere.
382
+ """
383
+ if provided is not None:
384
+ return provided.strip()
385
+ if sys.stdin.isatty():
386
+ try:
387
+ # Prompt on stderr, not via input()'s own argument, which writes to
388
+ # stdout. Keeps stdout parseable for --json callers.
389
+ print(prompt, end="", flush=True, file=sys.stderr)
390
+ return input().strip()
391
+ except (EOFError, KeyboardInterrupt):
392
+ pass
393
+ elif required:
394
+ raise NonInteractive(f"{flag} is required when there is no terminal to prompt on")
395
+ else:
396
+ return ""
397
+ if required:
398
+ raise NonInteractive(f"{flag} is required when there is no terminal to prompt on")
399
+ return ""
400
+
401
+
402
+ def run_login(url: str | None = None, *, email: str | None = None,
403
+ code: str | None = None, source: str | None = None,
404
+ referral: str | None = None, json_out: bool = False) -> bool:
405
+ """Email-OTP login. Interactive when run by a human, flag-driven for agents.
406
+
407
+ Fully interactive (no flags) behaves exactly as it always has. Supplying
408
+ --email alone performs step one only (send the code) and returns, because
409
+ the code arrives out of band; supplying --email and --code performs the
410
+ verification. That two-call shape is what lets a caller with no terminal
411
+ drive the whole flow.
412
+ """
295
413
  url = url or config.signup_url()
296
- email = input("Enter your email: ").strip()
414
+ # In --json mode the human narration goes to stderr so stdout carries only
415
+ # the receipt and a caller can pipe it straight into a parser.
416
+ say = (lambda *a, **k: print(*a, **k, file=sys.stderr)) if json_out else print
417
+ try:
418
+ email = _ask("Enter your email: ", email, "--email")
419
+ except NonInteractive as e:
420
+ say(f"{e}. See `obsideo login --help`.")
421
+ return False
297
422
  if not email:
298
- print("Email is required.")
423
+ say("Email is required.")
299
424
  return False
300
- print("Sending a verification code...", end="", flush=True)
425
+ # Step one — unless the caller already holds a code, in which case this is
426
+ # the SECOND of the two non-interactive calls and step one already ran.
427
+ # Sending another code here invalidates the one they are holding, and inside
428
+ # the shim's 30 s resend limit it fails the login outright, so the documented
429
+ # two-call flow could never complete. `source` is recorded by auth/start on
430
+ # the first call, so skipping it here loses no attribution.
431
+ if not code:
432
+ say("Sending a verification code...", end="", flush=True)
433
+ try:
434
+ login.start(email, url, source=source)
435
+ except login.LoginError as e:
436
+ say(f"\nCouldn't start signup: {e}")
437
+ return False
438
+ say(" sent.")
439
+ say(f"Check {email} for a verification code (it may be in spam).")
301
440
  try:
302
- login.start(email, url)
303
- except login.LoginError as e:
304
- print(f"\nCouldn't start signup: {e}")
305
- return False
306
- print(" sent.")
307
- print(f"Check {email} for a verification code (it may be in spam).")
308
- code = input("Enter verification code: ").strip()
309
- # Optional friend's referral code -> +1 GB (4 GB instead of 3). Blank = skip.
310
- referral_code = input("Referral code from a friend (optional, Enter to skip): ").strip()
311
- print("Verifying + provisioning storage...", end="", flush=True)
441
+ code = _ask("Enter verification code: ", code, "--code")
442
+ # Optional friend's referral code -> +1 GB (13 GB instead of 12).
443
+ referral_code = _ask("Referral code from a friend (optional, Enter to skip): ",
444
+ referral, "--referral", required=False)
445
+ except NonInteractive:
446
+ # There is no way to ask for the code here, and the code only arrives by
447
+ # email anyway. This is the normal path for a caller without a terminal:
448
+ # stop cleanly at step one, having actually sent the code, and tell the
449
+ # caller how to finish. Success, not failure.
450
+ if json_out:
451
+ print(json.dumps({"ok": True, "stage": "code_sent", "email": email,
452
+ "next": f"obsideo login --email {email} --code <6-digit code>"},
453
+ indent=2))
454
+ else:
455
+ print(f"\nCode sent. Finish with:\n obsideo login --email {email} --code <code>")
456
+ return True
457
+ say("Verifying + provisioning storage...", end="", flush=True)
312
458
  try:
313
459
  creds = login.verify(email, code, url, referral_code=referral_code or None)
314
460
  except login.LoginError as e:
315
- print(f"\nVerification failed: {e}")
461
+ say(f"\nVerification failed: {e}")
316
462
  return False
317
- print(" done.")
463
+ say(" done.")
318
464
  storage.reset_client()
319
465
  # Make sure the data key exists + nudge the user to back it up.
320
466
  crypto.data_key()
321
- print(f"\nYou're all set. {creds.get('quota_gb', 3)} GB free.")
467
+ if json_out:
468
+ # A receipt a caller can parse. Deliberately omits the secret key:
469
+ # it is already persisted to the credentials file, and printing it
470
+ # would put it in logs and terminal scrollback.
471
+ print(json.dumps({
472
+ "ok": True,
473
+ "stage": "provisioned",
474
+ "email": email,
475
+ "quota_gb": creds.get("quota_gb"),
476
+ "endpoint": creds.get("endpoint"),
477
+ "region": creds.get("region"),
478
+ "bucket": creds.get("bucket"),
479
+ "access_key": creds.get("access_key"),
480
+ "account_exists": creds.get("account_exists"),
481
+ "credentials_file": str(config.CREDENTIALS_FILE),
482
+ "data_key_file": str(crypto.DATA_KEY_FILE),
483
+ }, indent=2))
484
+ return True
485
+ print(f"\nYou're all set. {creds.get('quota_gb', 12)} GB free.")
322
486
  if referral_code:
323
487
  if creds.get("referral_applied"):
324
488
  print(f"Referral applied - enjoy the extra space! (code {referral_code.upper()})")
@@ -341,7 +505,7 @@ def run_login(url: str | None = None) -> bool:
341
505
 
342
506
  class ObsideoShell(cmd.Cmd):
343
507
  intro = (
344
- "\n Obsideo - encrypted storage we can't read.\n\n"
508
+ "\n Obsideo - your content is encrypted before it leaves your machine.\n\n"
345
509
  " Common commands:\n"
346
510
  " put <file> / get <name> upload / download\n"
347
511
  " ls / cd / mkdir browse your files\n"
@@ -381,9 +545,33 @@ class ObsideoShell(cmd.Cmd):
381
545
  return False
382
546
  return True
383
547
 
548
+ # ── help ────────────────────────────────────────────────────────────────
549
+ # Order the table reads in, roughly "get in, move around, move bytes, admin".
550
+ _HELP_ORDER = ["login", "ls", "cd", "pwd", "put", "get", "rm", "mkdir",
551
+ "info", "account", "sync", "config", "refer", "messages",
552
+ "about", "faq", "help", "exit"]
553
+
554
+ def do_help(self, arg):
555
+ """Show the command table, or 'help <command>' for one command."""
556
+ if arg:
557
+ return super().do_help(arg)
558
+ print("\n Obsideo commands (help <command> for detail)\n")
559
+ for name in self._HELP_ORDER:
560
+ fn = getattr(self, f"do_{name}", None)
561
+ if fn is None:
562
+ continue
563
+ # Summary = first sentence of the docstring, minus any "Usage:" tail,
564
+ # so the table can never drift from the per-command help.
565
+ doc = " ".join((fn.__doc__ or "").split())
566
+ summary = doc.split("Usage:")[0].split(". ")[0].strip().rstrip(".")
567
+ if len(summary) > 60:
568
+ summary = summary[:57].rstrip() + "..."
569
+ print(f" {name:<9} {summary}")
570
+ print("\n Aliases: upload = put, download = get, quit = exit\n")
571
+
384
572
  # ── login ───────────────────────────────────────────────────────────────
385
573
  def do_login(self, arg):
386
- """Sign up / log in with your email (email -> 3 GB free)."""
574
+ """Sign up / log in with your email (email -> 12 GB free)."""
387
575
  run_login()
388
576
  self._cwd = ""
389
577
  self._refresh_prompt()
@@ -396,14 +584,25 @@ class ObsideoShell(cmd.Cmd):
396
584
  target = _unquote(arg.strip())
397
585
  prefix = self._resolve(target) if target else self._cwd
398
586
  try:
399
- resp = storage.list_prefix(prefix)
587
+ # `ls` is what the post-login hint tells a new user to run, so it is
588
+ # often the first call a fresh credential ever makes.
589
+ resp = storage.with_propagation_retry(lambda: storage.list_prefix(prefix))
400
590
  except Exception as e:
401
591
  print(f"Error: {e}")
402
592
  return
593
+ # Names this account's key could not decrypt: written by another tool or
594
+ # under another data key. Mark them instead of showing a raw object key
595
+ # as though it were a filename.
596
+ opaque = resp.get("opaque") or set()
403
597
  for d in resp["folders"]:
404
- print(f" [dir] {d}/")
598
+ print(f" [dir] {'?' if d in opaque else ' '}{d}/")
405
599
  for f in resp["files"]:
406
- print(f" [file] {f['name']} {_human(f['size'])}")
600
+ mark = "?" if f["name"] in opaque else " "
601
+ print(f" [file] {mark}{f['name']} {_human(f['size'])}")
602
+ if opaque:
603
+ print("\n ? = name could not be decrypted with this account's key "
604
+ "(written by another tool,\n or before the key changed). "
605
+ "The raw object key is shown instead.")
407
606
  if not resp["folders"] and not resp["files"]:
408
607
  print(" (empty)")
409
608
 
@@ -583,7 +782,7 @@ class ObsideoShell(cmd.Cmd):
583
782
 
584
783
  # ── account ───────────────────────────────────────────────────────────────
585
784
  def do_account(self, arg):
586
- """Show your account: plan, storage used, and where your files/keys live."""
785
+ """Show your plan, usage, and where your files and keys live."""
587
786
  if not self._require_login():
588
787
  return
589
788
  from obsideo import sync as sync_mod
@@ -610,8 +809,17 @@ class ObsideoShell(cmd.Cmd):
610
809
  print(f" Used: {_human(used)}")
611
810
  if info.get("object_count"):
612
811
  print(f" Files: {info['object_count']} object(s)")
613
- if info.get("days_remaining"):
614
- print(f" Renews/expires in {info['days_remaining']} day(s)")
812
+ days = info.get("days_remaining")
813
+ if days:
814
+ # A no-expiry account (the promo tier) comes back as a ~100-year
815
+ # day count, which rendered as "Renews/expires in 36437 day(s)".
816
+ # That reads like a bug to anyone evaluating us, so name it.
817
+ try:
818
+ forever = int(days) > 3650
819
+ except (TypeError, ValueError):
820
+ forever = False
821
+ print(" Expires: never"
822
+ if forever else f" Renews/expires in {days} day(s)")
615
823
  else:
616
824
  print(" Plan: Free")
617
825
  try:
@@ -638,13 +846,13 @@ class ObsideoShell(cmd.Cmd):
638
846
  def do_about(self, arg):
639
847
  """What Obsideo is."""
640
848
  print("""
641
- OBSIDEO DRIVE - encrypted storage we can't read.
849
+ OBSIDEO DRIVE - content encrypted on your machine before upload.
642
850
 
643
851
  Your files are encrypted on your device (AES-256-GCM) before they ever leave
644
- it, then stored across three independent providers (RF=3). Obsideo's servers
852
+ it, then stored across three providers (RF=3). Obsideo's servers
645
853
  only ever see ciphertext - never your filenames, never your data.
646
854
 
647
- - Free: 3 GB, no card, no expiry.
855
+ - Free: 12 GB, no card, no expiry.
648
856
  - Your keys live only on your machine (~/.obsideo). Back up data.key - lose it
649
857
  and the data is unrecoverable by design. That's the point: not even we can read it.
650
858
  - Install / update: pip install -U obsideo-cli More: https://obsideo.io
@@ -659,7 +867,7 @@ class ObsideoShell(cmd.Cmd):
659
867
  A: No. They're encrypted on your device before upload; we only store ciphertext.
660
868
 
661
869
  Q: What's free?
662
- A: 3 GB, no credit card, no expiry.
870
+ A: 12 GB, no credit card, no expiry.
663
871
 
664
872
  Q: What if I lose my key?
665
873
  A: Your key is ~/.obsideo/data.key - back it up. Without it the data can't be
@@ -879,9 +1087,11 @@ def main():
879
1087
  print(f"obsideo-cli {config.VERSION}")
880
1088
  return
881
1089
 
882
- # Branded banner on every init (stderr, TTY-gated). Skip for `admin` so
883
- # operator tooling output stays clean.
884
- if not (argv and argv[0] == "admin"):
1090
+ # Branded banner: interactive sessions and `login` only (stderr, TTY-gated).
1091
+ # It used to print on every invocation, which meant six lines of ASCII art
1092
+ # ahead of two lines of answer on `obsideo ls`. First-run testing found that
1093
+ # reads as noise and pushes the actual output down the screen.
1094
+ if not argv or argv[0] == "login":
885
1095
  show_banner()
886
1096
 
887
1097
  # Standard --help / -h (cmd.Cmd would otherwise read "--help" as a command).
@@ -889,10 +1099,19 @@ def main():
889
1099
  ObsideoShell().onecmd("help")
890
1100
  return
891
1101
 
892
- # `obsideo login` is interactive and handled specially.
1102
+ # `obsideo login` is handled specially: it is the one command that may
1103
+ # prompt, and the one an agent most needs to drive without prompting.
893
1104
  if argv and argv[0] == "login":
894
- ok = run_login()
895
- if ok:
1105
+ opts, err = _parse_login_args(argv[1:])
1106
+ if err:
1107
+ print(err)
1108
+ print(LOGIN_USAGE)
1109
+ sys.exit(2)
1110
+ if opts.pop("help", False):
1111
+ print(LOGIN_USAGE)
1112
+ sys.exit(0)
1113
+ ok = run_login(**opts)
1114
+ if ok and not opts.get("json_out"):
896
1115
  show_status()
897
1116
  sys.exit(0 if ok else 1)
898
1117
 
@@ -909,6 +1128,7 @@ def main():
909
1128
  # status line here — one-shots stay fast and scriptable.
910
1129
  if argv:
911
1130
  shell.onecmd(" ".join(argv))
1131
+ show_next_steps(argv[0])
912
1132
  return
913
1133
 
914
1134
  # Interactive session ("initialization"): offer an update if one's out.
@@ -916,7 +1136,7 @@ def main():
916
1136
 
917
1137
  # First-run nudge: not logged in -> offer login.
918
1138
  if not config.is_logged_in():
919
- print("Welcome to Obsideo - encrypted storage we can't read.")
1139
+ print("Welcome to Obsideo - your content is encrypted before it leaves your machine.")
920
1140
  if input("Log in / sign up now? (Y/n): ").strip().lower() in ("", "y", "yes"):
921
1141
  if not run_login():
922
1142
  return
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.3.1
4
- Summary: Obsideo Cloud - encrypted storage we can't read, for people and AI agents. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
3
+ Version: 0.5.0
4
+ Summary: Obsideo Cloud CLI - client-side encrypted storage for people and AI agents: content is encrypted on your machine before upload. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
7
7
  Project-URL: Documentation, https://obsideo.io/docs/
@@ -9,20 +9,22 @@ Project-URL: Agent integration (AGENTS.md), https://obsideo.io/agents.md
9
9
  Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encryption,object storage,free tier,cli,sync
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
+ License-File: LICENSE
12
13
  Requires-Dist: boto3>=1.28
13
14
  Requires-Dist: cryptography>=41.0
14
15
  Requires-Dist: certifi
16
+ Dynamic: license-file
15
17
 
16
18
  # obsideo-cli
17
19
 
18
- **Encrypted storage we can't read.** Save, browse, and sync whatever you want
20
+ **Client-side encrypted storage: your content is encrypted on your machine before upload, so the platform cannot read it.** File and folder names are encrypted too, by default (AES-SIV, keyed off your data key), so the platform sees opaque tokens rather than your filenames. Save, browse, and sync whatever you want
19
21
  from your terminal. Files are encrypted on your machine before they leave, so
20
22
  Obsideo's gateway, coordinator, and storage providers only ever see ciphertext.
21
- Your data lands on three independent providers (RF=3).
23
+ Your data lands on three providers (RF=3).
22
24
 
23
25
  ```
24
26
  pip install obsideo-cli
25
- obsideo login # email -> 3 GB free
27
+ obsideo login # email -> 12 GB free
26
28
  obsideo # open the shell
27
29
  ```
28
30
 
@@ -33,7 +35,7 @@ $ obsideo login
33
35
  Enter your email: you@example.com
34
36
  Check your email for a verification code.
35
37
  Enter verification code: 482913
36
- You're all set. 3 GB free.
38
+ You're all set. 12 GB free.
37
39
  ```
38
40
 
39
41
  Login is handled by Obsideo's signup service at **`signup.obsideo.io`**: it emails
@@ -59,7 +61,7 @@ obsideo:/trip/ get cat.jpg ./downloaded.jpg
59
61
 
60
62
  | Command | Description |
61
63
  |---|---|
62
- | `obsideo login` | Sign up / log in with your email (3 GB free) |
64
+ | `obsideo login` | Sign up / log in with your email (12 GB free) |
63
65
  | `ls [path]` | List files and folders |
64
66
  | `cd <path>` / `pwd` | Move around / show location |
65
67
  | `put <local> [name]` | Encrypt + upload a file, or a whole folder (recursive). `--no-encrypt` to store as-is |
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  obsideo/__init__.py
@@ -11,7 +11,11 @@ import json
11
11
  import os
12
12
  from pathlib import Path
13
13
 
14
- CONFIG_DIR = Path.home() / ".obsideo"
14
+ # OBSIDEO_CONFIG_DIR relocates the whole config directory. Without it there is
15
+ # no way to exercise the login path without overwriting the real user's
16
+ # credentials, which is exactly how a stubbed test clobbered a live account
17
+ # once. Also lets a user keep a second account or a sandbox side by side.
18
+ CONFIG_DIR = Path(os.environ.get("OBSIDEO_CONFIG_DIR") or (Path.home() / ".obsideo"))
15
19
  CREDENTIALS_FILE = CONFIG_DIR / "credentials"
16
20
  CONFIG_FILE = CONFIG_DIR / "config.json"
17
21
 
@@ -36,10 +36,16 @@ def _post_json(url: str, payload: dict) -> dict:
36
36
  raise LoginError(f"could not reach {url}: {e.reason}")
37
37
 
38
38
 
39
- def start(email: str, url: str | None = None) -> None:
40
- """Request a verification code be emailed to `email`."""
39
+ def start(email: str, url: str | None = None, source: str | None = None) -> None:
40
+ """Request a verification code be emailed to `email`.
41
+
42
+ `source` is optional free-text attribution recorded on the account ("where
43
+ did this signup come from"). It never gates signup. Defaults to "cli" so
44
+ terminal signups stop being indistinguishable from raw API calls.
45
+ """
41
46
  url = url or config.signup_url()
42
- _post_json(f"{url}/v1/auth/start", {"email": email})
47
+ payload = {"email": email, "source": source or "cli"}
48
+ _post_json(f"{url}/v1/auth/start", payload)
43
49
 
44
50
 
45
51
  def verify(email: str, code: str, url: str | None = None,