obris-cli 0.4.0__tar.gz → 0.4.2__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 (38) hide show
  1. {obris_cli-0.4.0 → obris_cli-0.4.2}/PKG-INFO +1 -1
  2. {obris_cli-0.4.0 → obris_cli-0.4.2}/pyproject.toml +1 -1
  3. obris_cli-0.4.2/ruff.toml +2 -0
  4. obris_cli-0.4.0/src/obris/commands/auth.py → obris_cli-0.4.2/src/obris/auth/session.py +68 -128
  5. obris_cli-0.4.2/src/obris/commands/auth.py +173 -0
  6. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/config.py +28 -1
  7. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/mapping.py +3 -1
  8. obris_cli-0.4.2/src/obris/utils/__init__.py +0 -0
  9. {obris_cli-0.4.0 → obris_cli-0.4.2}/.claude/settings.local.json +0 -0
  10. {obris_cli-0.4.0 → obris_cli-0.4.2}/.gitignore +0 -0
  11. {obris_cli-0.4.0 → obris_cli-0.4.2}/README.md +0 -0
  12. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/__init__.py +0 -0
  13. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/api/__init__.py +0 -0
  14. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/api/client.py +0 -0
  15. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/api/knowledge.py +0 -0
  16. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/api/topics.py +0 -0
  17. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/assets/icon.png +0 -0
  18. {obris_cli-0.4.0/src/obris/commands → obris_cli-0.4.2/src/obris/auth}/__init__.py +0 -0
  19. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/cli.py +0 -0
  20. {obris_cli-0.4.0/src/obris/sync → obris_cli-0.4.2/src/obris/commands}/__init__.py +0 -0
  21. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/commands/env.py +0 -0
  22. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/commands/knowledge.py +0 -0
  23. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/commands/save.py +0 -0
  24. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/commands/sync.py +0 -0
  25. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/commands/topic.py +0 -0
  26. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/output.py +0 -0
  27. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/routes.py +0 -0
  28. {obris_cli-0.4.0/src/obris/utils → obris_cli-0.4.2/src/obris/sync}/__init__.py +0 -0
  29. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/commands.py +0 -0
  30. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/constants.py +0 -0
  31. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/engine.py +0 -0
  32. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/io.py +0 -0
  33. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/models.py +0 -0
  34. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/sync/state.py +0 -0
  35. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/utils/capture.py +0 -0
  36. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/utils/notify.py +0 -0
  37. {obris_cli-0.4.0 → obris_cli-0.4.2}/src/obris/utils/upload.py +0 -0
  38. {obris_cli-0.4.0 → obris_cli-0.4.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obris-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Save, organize, and access your knowledge from the command line
5
5
  Project-URL: Homepage, https://obris.ai
6
6
  Project-URL: Repository, https://github.com/obris-dev/obris
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obris-cli"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,2 @@
1
+ extend = "../ruff.toml"
2
+ target-version = "py310"
@@ -1,6 +1,6 @@
1
- import contextlib
1
+ """Device auth session lifecycle: start, poll, check, finalize."""
2
+
2
3
  import time
3
- import webbrowser
4
4
 
5
5
  import click
6
6
  import requests
@@ -11,145 +11,52 @@ from obris.api.topics import list_topics
11
11
  from obris.output import as_json, is_json
12
12
 
13
13
  POLL_INTERVAL = 2
14
- # Server session lives 15 minutes; a session completed near the edge stays
15
- # readable for another COMPLETED_TTL (60s). Give the CLI a buffer past the
16
- # session TTL so we don't time out in the same second the user authorizes.
17
- POLL_TIMEOUT = 960
14
+ # Server session lives 15 minutes (SESSION_TTL); a completed session is
15
+ # readable for another COMPLETED_TTL (300s). Clamp the CLI deadline past
16
+ # both so we don't time out in the same second the user authorizes.
17
+ POLL_TIMEOUT = 1200
18
18
  POLL_MAX_BACKOFF = 30
19
19
 
20
20
 
21
- @click.group("auth")
22
- def auth():
23
- """Manage authentication."""
24
-
25
-
26
- @auth.command("login")
27
- def auth_login():
28
- """Authenticate via browser (recommended).
29
-
30
- Opens a browser to log in. Works from any environment —
31
- copy the link if the browser doesn't open automatically.
32
- """
33
- api_base = config.get_api_base()
34
- app_base = config.get_app_base()
35
-
21
+ def start_session(api_base):
22
+ """POST a new device auth session and return its session_id."""
36
23
  try:
37
24
  resp = requests.post(f"{api_base}/{routes.device_sessions()}", timeout=10)
38
25
  resp.raise_for_status()
39
26
  payload = resp.json()
40
- session_id = payload["session_id"]
27
+ return payload["session_id"]
41
28
  except requests.RequestException as e:
42
29
  raise SystemExit(f"Failed to start login session: {e}") from e
43
30
  except (ValueError, KeyError) as e:
44
31
  raise SystemExit(f"Unexpected response from login session endpoint: {e}") from e
45
- url = f"{app_base}/auth/device/{session_id}"
46
-
47
- if not is_json():
48
- click.echo("\nTo authenticate, open this URL in your browser:\n")
49
- click.echo(f" {url}\n")
50
32
 
51
- with contextlib.suppress(webbrowser.Error):
52
- webbrowser.open(url)
53
33
 
54
- if not is_json():
55
- click.echo("Waiting for authentication...")
34
+ def check_session(api_base, session_id):
35
+ """One-shot session check used by `auth complete`.
56
36
 
37
+ Returns the session dict if readable, or None if the session is
38
+ gone (404). Unlike `poll_for_completion`, this does not loop or
39
+ retry — callers are expected to invoke it at most once per command
40
+ run.
41
+ """
42
+ poll_url = f"{api_base}/{routes.device_session(session_id)}"
57
43
  try:
58
- session = _poll_for_completion(api_base, session_id)
59
- except KeyboardInterrupt:
60
- click.echo("\nLogin cancelled.", err=True)
61
- raise SystemExit(130) from None
62
-
63
- email = session.get("email")
64
- config.save_tokens(
65
- access_token=session["access_token"],
66
- refresh_token=session["refresh_token"],
67
- expires_in=session.get("expires_in", 3600),
68
- client_id=session.get("client_id", ""),
69
- )
70
-
71
- scratch_id = _detect_scratch()
72
-
73
- if is_json():
74
- as_json(
75
- {
76
- "env": config.get_active_env(),
77
- "email": email,
78
- "scratch_topic_id": scratch_id,
79
- }
80
- )
81
- return
82
-
83
- if email:
84
- click.echo(f"Logged in as {email}")
85
- env = config.get_active_env()
86
- if scratch_id:
87
- click.echo(f"[{env}] Scratch topic: {scratch_id}")
88
- else:
89
- click.echo(f"[{env}] No 'Scratch' topic found.")
90
-
91
-
92
- @auth.command("status")
93
- def auth_status():
94
- """Show current authentication status."""
95
- env = config.get_active_env()
96
- data = config._env_data()
97
- token = data.get(config.KEY_ACCESS_TOKEN)
98
-
99
- if not token:
100
- if is_json():
101
- as_json({"env": env, "authenticated": False})
102
- return
103
- click.echo(f"[{env}] Not authenticated.")
104
- return
105
-
106
- expires_at = data.get(config.KEY_TOKEN_EXPIRES_AT, "")
107
- scratch = data.get(config.KEY_SCRATCH_TOPIC)
108
-
109
- if is_json():
110
- as_json(
111
- {
112
- "env": env,
113
- "authenticated": True,
114
- "token_expires_at": expires_at,
115
- "scratch_topic_id": scratch,
116
- }
117
- )
118
- return
119
-
120
- click.echo(f"[{env}] Authenticated (OAuth token)")
121
- if expires_at:
122
- click.echo(f"[{env}] Token expires: {expires_at}")
123
- if scratch:
124
- click.echo(f"[{env}] Scratch topic: {scratch}")
125
-
126
-
127
- @auth.command("logout")
128
- def auth_logout():
129
- """Remove stored credentials."""
130
- env = config.get_active_env()
131
- data = config._env_data()
132
-
133
- if not data.get(config.KEY_ACCESS_TOKEN):
134
- if is_json():
135
- as_json({"env": env, "logged_out": False})
136
- return
137
- click.echo(f"[{env}] Not authenticated.")
138
- return
139
-
140
- config.clear_tokens()
141
- if is_json():
142
- as_json({"env": env, "logged_out": True})
143
- return
144
- click.echo(f"[{env}] Logged out.")
44
+ resp = requests.get(poll_url, timeout=10)
45
+ except requests.RequestException as e:
46
+ raise SystemExit(f"Failed to check login session: {e}") from e
145
47
 
48
+ if resp.status_code == 404:
49
+ return None
50
+ if not resp.ok:
51
+ raise SystemExit(f"Failed to check login session ({resp.status_code}): {resp.text}")
146
52
 
147
- # ---------------------------------------------------------------------------
148
- # Helpers
149
- # ---------------------------------------------------------------------------
53
+ try:
54
+ return resp.json()
55
+ except ValueError as e:
56
+ raise SystemExit(f"Unexpected response from login session endpoint: {e}") from e
150
57
 
151
58
 
152
- def _poll_for_completion(api_base, session_id):
59
+ def poll_for_completion(api_base, session_id):
153
60
  """Poll the session endpoint until completed or expired.
154
61
 
155
62
  Backs off on server errors (5xx, 429) with exponential delay,
@@ -193,7 +100,6 @@ def _poll_for_completion(api_base, session_id):
193
100
  raise SystemExit("Session completed but no token received.")
194
101
  return data
195
102
 
196
- # Still pending — reset backoff and wait normally
197
103
  last_server_error = None
198
104
  backoff = POLL_INTERVAL
199
105
  _sleep_within(POLL_INTERVAL, deadline)
@@ -203,12 +109,38 @@ def _poll_for_completion(api_base, session_id):
203
109
  raise SystemExit("Login timed out. Run 'obris auth login' to try again.")
204
110
 
205
111
 
206
- def _sleep_within(seconds, deadline):
207
- """Sleep for up to `seconds`, clamped so we never sleep past the deadline."""
208
- remaining = deadline - time.time()
209
- if remaining <= 0:
112
+ def finalize_login(session):
113
+ """Save tokens from a completed session, detect the Scratch topic,
114
+ and report to the user. Shared by the blocking and non-blocking
115
+ login paths.
116
+ """
117
+ email = session.get("email")
118
+ config.save_tokens(
119
+ access_token=session["access_token"],
120
+ refresh_token=session["refresh_token"],
121
+ expires_in=session.get("expires_in", 3600),
122
+ client_id=session.get("client_id", ""),
123
+ )
124
+
125
+ scratch_id = _detect_scratch()
126
+ env = config.get_active_env()
127
+
128
+ if is_json():
129
+ as_json(
130
+ {
131
+ "env": env,
132
+ "email": email,
133
+ "scratch_topic_id": scratch_id,
134
+ }
135
+ )
210
136
  return
211
- time.sleep(min(seconds, remaining))
137
+
138
+ if email:
139
+ click.echo(f"Logged in as {email}")
140
+ if scratch_id:
141
+ click.echo(f"[{env}] Scratch topic: {scratch_id}")
142
+ else:
143
+ click.echo(f"[{env}] No 'Scratch' topic found.")
212
144
 
213
145
 
214
146
  def _detect_scratch():
@@ -228,3 +160,11 @@ def _detect_scratch():
228
160
  cfg[env][config.KEY_SCRATCH_TOPIC] = scratch_id
229
161
  config.save(cfg)
230
162
  return scratch_id
163
+
164
+
165
+ def _sleep_within(seconds, deadline):
166
+ """Sleep for up to `seconds`, clamped so we never sleep past the deadline."""
167
+ remaining = deadline - time.time()
168
+ if remaining <= 0:
169
+ return
170
+ time.sleep(min(seconds, remaining))
@@ -0,0 +1,173 @@
1
+ import contextlib
2
+ import webbrowser
3
+
4
+ import click
5
+
6
+ from obris import config
7
+ from obris.auth.session import check_session, finalize_login, poll_for_completion, start_session
8
+ from obris.output import as_json, is_json
9
+
10
+
11
+ @click.group("auth")
12
+ def auth():
13
+ """Manage authentication."""
14
+
15
+
16
+ @auth.command("login")
17
+ @click.option(
18
+ "--no-wait",
19
+ is_flag=True,
20
+ help="Print the login URL and exit without polling. Finish with `obris auth complete`.",
21
+ )
22
+ def auth_login(no_wait):
23
+ """Authenticate via browser (recommended).
24
+
25
+ Opens a browser to log in. Works from any environment — copy the
26
+ link if the browser doesn't open automatically.
27
+
28
+ By default, the CLI polls in the foreground and exits once login is
29
+ complete. Pass `--no-wait` to print the URL and exit immediately;
30
+ when you (or your user) have authorized in the browser, run
31
+ `obris auth complete` to finalize. This mode is required for LLMs
32
+ and scripted contexts, where blocking the caller would prevent the
33
+ URL from being relayed to the user.
34
+ """
35
+ api_base = config.get_api_base()
36
+ app_base = config.get_app_base()
37
+
38
+ session_id = start_session(api_base)
39
+ url = f"{app_base}/auth/device/{session_id}"
40
+
41
+ if not is_json():
42
+ click.echo("\nTo authenticate, open this URL in your browser:\n")
43
+ click.echo(f" {url}\n")
44
+
45
+ with contextlib.suppress(webbrowser.Error):
46
+ webbrowser.open(url)
47
+
48
+ env = config.get_active_env()
49
+
50
+ if no_wait:
51
+ config.save_pending_session(session_id)
52
+ if is_json():
53
+ as_json({"env": env, "session_id": session_id, "url": url, "status": "pending"})
54
+ return
55
+ click.echo(f"[{env}] After authorizing, run: obris auth complete")
56
+ return
57
+
58
+ if not is_json():
59
+ click.echo("Waiting for authentication...")
60
+
61
+ try:
62
+ session = poll_for_completion(api_base, session_id)
63
+ except KeyboardInterrupt:
64
+ click.echo("\nLogin cancelled.", err=True)
65
+ raise SystemExit(130) from None
66
+
67
+ finalize_login(session)
68
+
69
+
70
+ @auth.command("status")
71
+ def auth_status():
72
+ """Show current authentication status (read-only).
73
+
74
+ Reports whether an access token is stored for the active env and,
75
+ if so, when it expires. If a login was started with `--no-wait` and
76
+ hasn't been finalized yet, reports that the session is pending.
77
+ This command never mutates stored state; run `obris auth complete`
78
+ to finalize a pending session.
79
+ """
80
+ env = config.get_active_env()
81
+ data = config._env_data()
82
+ token = data.get(config.KEY_ACCESS_TOKEN)
83
+ pending = config.get_pending_session()
84
+
85
+ if not token:
86
+ if is_json():
87
+ as_json({"env": env, "authenticated": False, "pending": bool(pending)})
88
+ return
89
+ if pending:
90
+ click.echo(f"[{env}] Login pending. Finish with: obris auth complete")
91
+ else:
92
+ click.echo(f"[{env}] Not authenticated.")
93
+ return
94
+
95
+ expires_at = data.get(config.KEY_TOKEN_EXPIRES_AT, "")
96
+ scratch = data.get(config.KEY_SCRATCH_TOPIC)
97
+
98
+ if is_json():
99
+ as_json(
100
+ {
101
+ "env": env,
102
+ "authenticated": True,
103
+ "token_expires_at": expires_at,
104
+ "scratch_topic_id": scratch,
105
+ }
106
+ )
107
+ return
108
+
109
+ click.echo(f"[{env}] Authenticated (OAuth token)")
110
+ if expires_at:
111
+ click.echo(f"[{env}] Token expires: {expires_at}")
112
+ if scratch:
113
+ click.echo(f"[{env}] Scratch topic: {scratch}")
114
+
115
+
116
+ @auth.command("complete")
117
+ def auth_complete():
118
+ """Finalize a pending `auth login --no-wait` session.
119
+
120
+ Checks the pending session once and, if the user has authorized in
121
+ the browser, saves the tokens and clears the pending state. Exits
122
+ non-zero if no pending session exists or the session has expired.
123
+ """
124
+ env = config.get_active_env()
125
+ pending = config.get_pending_session()
126
+
127
+ if not pending:
128
+ if is_json():
129
+ as_json({"env": env, "completed": False, "reason": "no_pending_session"})
130
+ raise SystemExit(1)
131
+ raise SystemExit(f"[{env}] No pending login session. Start one with: obris auth login --no-wait")
132
+
133
+ api_base = config.get_api_base()
134
+ session = check_session(api_base, pending)
135
+
136
+ if session is None:
137
+ config.clear_pending_session()
138
+ if is_json():
139
+ as_json({"env": env, "completed": False, "reason": "session_expired"})
140
+ raise SystemExit(1)
141
+ raise SystemExit(f"[{env}] Pending login session expired. Run: obris auth login --no-wait")
142
+
143
+ if session.get("status") != "completed":
144
+ app_base = config.get_app_base()
145
+ url = f"{app_base}/auth/device/{pending}"
146
+ if is_json():
147
+ as_json({"env": env, "completed": False, "reason": "still_pending", "url": url})
148
+ raise SystemExit(1)
149
+ raise SystemExit(f"[{env}] Login still pending. Open: {url}")
150
+
151
+ finalize_login(session)
152
+ config.clear_pending_session()
153
+
154
+
155
+ @auth.command("logout")
156
+ def auth_logout():
157
+ """Remove stored credentials."""
158
+ env = config.get_active_env()
159
+ data = config._env_data()
160
+
161
+ if not data.get(config.KEY_ACCESS_TOKEN):
162
+ if is_json():
163
+ as_json({"env": env, "logged_out": False})
164
+ return
165
+ click.echo(f"[{env}] Not authenticated.")
166
+ return
167
+
168
+ config.clear_tokens()
169
+ config.clear_pending_session()
170
+ if is_json():
171
+ as_json({"env": env, "logged_out": True})
172
+ return
173
+ click.echo(f"[{env}] Logged out.")
@@ -1,8 +1,10 @@
1
1
  import json
2
2
  import os
3
- from datetime import UTC, datetime, timedelta
3
+ from datetime import datetime, timedelta, timezone
4
4
  from pathlib import Path
5
5
 
6
+ UTC = timezone.utc
7
+
6
8
  import requests
7
9
 
8
10
  from obris import routes
@@ -22,6 +24,7 @@ KEY_CLIENT_ID = "client_id"
22
24
  KEY_DEFAULT_ENV = "default_env"
23
25
  KEY_ENVIRONMENTS = "environments"
24
26
  KEY_SCRATCH_TOPIC = "scratch_topic_id"
27
+ KEY_PENDING_SESSION_ID = "pending_session_id"
25
28
 
26
29
  REFRESH_BUFFER = timedelta(minutes=5)
27
30
 
@@ -144,6 +147,30 @@ def clear_tokens():
144
147
  save(cfg)
145
148
 
146
149
 
150
+ def save_pending_session(session_id):
151
+ """Remember a session_id started by `auth login --no-wait` so a later
152
+ `auth status` call knows which session to finalize."""
153
+ env = get_active_env()
154
+ cfg = load()
155
+ cfg.setdefault(env, {})
156
+ cfg[env][KEY_PENDING_SESSION_ID] = session_id
157
+ save(cfg)
158
+
159
+
160
+ def get_pending_session():
161
+ """Return the pending session_id for the active environment, or None."""
162
+ return _env_data().get(KEY_PENDING_SESSION_ID)
163
+
164
+
165
+ def clear_pending_session():
166
+ """Drop any pending session_id for the active environment."""
167
+ env = get_active_env()
168
+ cfg = load()
169
+ env_data = cfg.get(env, {})
170
+ env_data.pop(KEY_PENDING_SESSION_ID, None)
171
+ save(cfg)
172
+
173
+
147
174
  def _refresh_if_needed():
148
175
  """Refresh the access token if it expires within the buffer window.
149
176
 
@@ -1,9 +1,11 @@
1
1
  """Filename computation, hashing, and file utilities for sync."""
2
2
 
3
3
  import hashlib
4
- from datetime import UTC, datetime
4
+ from datetime import datetime, timezone
5
5
  from pathlib import Path
6
6
 
7
+ UTC = timezone.utc
8
+
7
9
  CONFLICT_MARKER = "(conflict "
8
10
  HASH_CHUNK_SIZE = 64 * 1024
9
11
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes