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.
- obsideo_cli-0.5.0/LICENSE +21 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/PKG-INFO +9 -7
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/README.md +5 -5
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/cli.py +258 -38
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/PKG-INFO +9 -7
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/SOURCES.txt +1 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/config.py +5 -1
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/login.py +9 -3
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/storage.py +373 -290
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/pyproject.toml +2 -2
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/tests/test_cli.py +20 -2
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/tests/test_core.py +91 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo/sync.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.3.1 → obsideo_cli-0.5.0}/obsideo_core/names.py +0 -0
- {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.
|
|
4
|
-
Summary: Obsideo Cloud - encrypted storage
|
|
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
|
-
**
|
|
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
|
|
23
|
+
Your data lands on three providers (RF=3).
|
|
22
24
|
|
|
23
25
|
```
|
|
24
26
|
pip install obsideo-cli
|
|
25
|
-
obsideo login # email ->
|
|
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.
|
|
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 (
|
|
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
|
-
**
|
|
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
|
|
6
|
+
Your data lands on three providers (RF=3).
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
pip install obsideo-cli
|
|
10
|
-
obsideo login # email ->
|
|
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.
|
|
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 (
|
|
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 ->
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
+
say("Email is required.")
|
|
299
424
|
return False
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
461
|
+
say(f"\nVerification failed: {e}")
|
|
316
462
|
return False
|
|
317
|
-
|
|
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
|
-
|
|
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
|
|
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 ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
883
|
-
#
|
|
884
|
-
|
|
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
|
|
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
|
-
|
|
895
|
-
if
|
|
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
|
|
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.
|
|
4
|
-
Summary: Obsideo Cloud - encrypted storage
|
|
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
|
-
**
|
|
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
|
|
23
|
+
Your data lands on three providers (RF=3).
|
|
22
24
|
|
|
23
25
|
```
|
|
24
26
|
pip install obsideo-cli
|
|
25
|
-
obsideo login # email ->
|
|
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.
|
|
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 (
|
|
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 |
|
|
@@ -11,7 +11,11 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
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,
|