obris-cli 0.2.0__tar.gz → 0.4.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.
- obris_cli-0.4.0/.claude/settings.local.json +8 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/.gitignore +3 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/PKG-INFO +1 -1
- {obris_cli-0.2.0 → obris_cli-0.4.0}/README.md +11 -6
- {obris_cli-0.2.0 → obris_cli-0.4.0}/pyproject.toml +1 -1
- obris_cli-0.4.0/src/obris/api/__init__.py +30 -0
- obris_cli-0.4.0/src/obris/api/client.py +64 -0
- obris_cli-0.4.0/src/obris/api/knowledge.py +53 -0
- obris_cli-0.4.0/src/obris/api/topics.py +69 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/cli.py +12 -1
- obris_cli-0.4.0/src/obris/commands/auth.py +230 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/env.py +3 -6
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/save.py +6 -1
- obris_cli-0.4.0/src/obris/commands/sync.py +180 -0
- obris_cli-0.4.0/src/obris/config.py +230 -0
- obris_cli-0.4.0/src/obris/routes.py +45 -0
- obris_cli-0.4.0/src/obris/sync/commands.py +61 -0
- obris_cli-0.4.0/src/obris/sync/constants.py +5 -0
- obris_cli-0.4.0/src/obris/sync/engine.py +253 -0
- obris_cli-0.4.0/src/obris/sync/io.py +50 -0
- obris_cli-0.4.0/src/obris/sync/mapping.py +120 -0
- obris_cli-0.4.0/src/obris/sync/models.py +75 -0
- obris_cli-0.4.0/src/obris/sync/state.py +126 -0
- obris_cli-0.4.0/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/upload.py +4 -2
- {obris_cli-0.2.0 → obris_cli-0.4.0}/uv.lock +1 -1
- obris_cli-0.2.0/src/obris/api/__init__.py +0 -14
- obris_cli-0.2.0/src/obris/api/client.py +0 -47
- obris_cli-0.2.0/src/obris/api/knowledge.py +0 -17
- obris_cli-0.2.0/src/obris/api/topics.py +0 -14
- obris_cli-0.2.0/src/obris/commands/auth.py +0 -54
- obris_cli-0.2.0/src/obris/config.py +0 -123
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/__init__.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/output.py +0 -0
- {obris_cli-0.2.0/src/obris/utils → obris_cli-0.4.0/src/obris/sync}/__init__.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/notify.py +0 -0
|
@@ -26,18 +26,23 @@ pip install obris-cli
|
|
|
26
26
|
## Authenticate
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
obris auth
|
|
29
|
+
obris auth login
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Opens a browser to log in. The CLI waits, you authorize, done. Works from any machine — the login URL can be opened on any device with a browser. Connects to Obris Cloud by default. See [Selfhosted](#selfhosted) for your own instance.
|
|
33
33
|
|
|
34
34
|
## Commands
|
|
35
35
|
|
|
36
36
|
| Command | Description |
|
|
37
37
|
|---------|-------------|
|
|
38
|
-
| `obris auth` | Authenticate
|
|
38
|
+
| `obris auth login` | Authenticate via browser |
|
|
39
|
+
| `obris auth status` | Show current authentication |
|
|
40
|
+
| `obris auth logout` | Remove stored credentials |
|
|
39
41
|
| `obris save <file>` | Save a file to a topic |
|
|
40
42
|
| `obris save --screenshot` | Take a screenshot and save it |
|
|
43
|
+
| `obris sync [path]` | Sync a directory with an Obris topic |
|
|
44
|
+
| `obris sync add <file>` | Add a local file to a synced topic |
|
|
45
|
+
| `obris sync link <file> -i <id>` | Relink a renamed file |
|
|
41
46
|
| `obris topic list` | List all topics |
|
|
42
47
|
| `obris topic view <id>` | View a topic and its knowledge items |
|
|
43
48
|
| `obris knowledge view <id>` | View a knowledge item |
|
|
@@ -51,14 +56,13 @@ You'll be prompted for your API key (or pass it directly with `--key`). Get your
|
|
|
51
56
|
|
|
52
57
|
## Selfhosted
|
|
53
58
|
|
|
54
|
-
Point the CLI at your own Obris instance
|
|
59
|
+
Point the CLI at your own Obris instance:
|
|
55
60
|
|
|
56
61
|
```bash
|
|
57
62
|
obris env add myserver --url https://obris.example.com
|
|
63
|
+
obris --env myserver auth login
|
|
58
64
|
```
|
|
59
65
|
|
|
60
|
-
Generate an API key from your instance at `https://your-domain/api-keys`.
|
|
61
|
-
|
|
62
66
|
## JSON output
|
|
63
67
|
|
|
64
68
|
Every command supports `--json` for machine-readable output:
|
|
@@ -66,6 +70,7 @@ Every command supports `--json` for machine-readable output:
|
|
|
66
70
|
```bash
|
|
67
71
|
obris --json topic list
|
|
68
72
|
obris --json knowledge view <id>
|
|
73
|
+
obris --json auth status
|
|
69
74
|
```
|
|
70
75
|
|
|
71
76
|
## License
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from obris.api.client import ApiError, delete, get, patch, post
|
|
2
|
+
from obris.api.knowledge import (
|
|
3
|
+
knowledge_add,
|
|
4
|
+
knowledge_delete,
|
|
5
|
+
knowledge_detail,
|
|
6
|
+
knowledge_move,
|
|
7
|
+
knowledge_replace_file,
|
|
8
|
+
knowledge_update,
|
|
9
|
+
)
|
|
10
|
+
from obris.api.topics import create_topic, get_topic, list_all_knowledge, list_all_topics, list_knowledge, list_topics
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ApiError",
|
|
14
|
+
"create_topic",
|
|
15
|
+
"delete",
|
|
16
|
+
"get",
|
|
17
|
+
"get_topic",
|
|
18
|
+
"knowledge_add",
|
|
19
|
+
"knowledge_delete",
|
|
20
|
+
"knowledge_detail",
|
|
21
|
+
"knowledge_move",
|
|
22
|
+
"knowledge_replace_file",
|
|
23
|
+
"knowledge_update",
|
|
24
|
+
"list_all_knowledge",
|
|
25
|
+
"list_all_topics",
|
|
26
|
+
"list_knowledge",
|
|
27
|
+
"list_topics",
|
|
28
|
+
"patch",
|
|
29
|
+
"post",
|
|
30
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared API client with auth, base URL, and error handling."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from obris.config import auth_headers, get_api_base
|
|
6
|
+
|
|
7
|
+
TIMEOUT = 30
|
|
8
|
+
UPLOAD_TIMEOUT = 120
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiError(Exception):
|
|
12
|
+
def __init__(self, message, status_code=None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _url(path):
|
|
18
|
+
return f"{get_api_base()}/{path.lstrip('/')}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check(resp, action="Request"):
|
|
22
|
+
if not resp.ok:
|
|
23
|
+
raise ApiError(f"{action} failed ({resp.status_code}): {resp.text}", status_code=resp.status_code)
|
|
24
|
+
return resp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unwrap(body):
|
|
28
|
+
"""Handle paginated ({"results": [...]}) or plain list responses."""
|
|
29
|
+
if isinstance(body, dict) and "results" in body:
|
|
30
|
+
return body["results"]
|
|
31
|
+
return body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get(path, params=None, *, action="Request", unwrap=False):
|
|
35
|
+
resp = requests.get(_url(path), headers=auth_headers(), params=params, timeout=TIMEOUT)
|
|
36
|
+
_check(resp, action)
|
|
37
|
+
body = resp.json()
|
|
38
|
+
return _unwrap(body) if unwrap else body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def post(path, json=None, *, action="Request", unwrap=False):
|
|
42
|
+
resp = requests.post(_url(path), headers=auth_headers(), json=json, timeout=TIMEOUT)
|
|
43
|
+
_check(resp, action)
|
|
44
|
+
body = resp.json()
|
|
45
|
+
return _unwrap(body) if unwrap else body
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def patch(path, json=None, *, action="Request", unwrap=False):
|
|
49
|
+
resp = requests.patch(_url(path), headers=auth_headers(), json=json, timeout=TIMEOUT)
|
|
50
|
+
_check(resp, action)
|
|
51
|
+
body = resp.json()
|
|
52
|
+
return _unwrap(body) if unwrap else body
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def post_form(path, files=None, data=None, *, action="Upload", unwrap=False, timeout=UPLOAD_TIMEOUT):
|
|
56
|
+
resp = requests.post(_url(path), headers=auth_headers(), files=files, data=data, timeout=timeout)
|
|
57
|
+
_check(resp, action)
|
|
58
|
+
body = resp.json()
|
|
59
|
+
return _unwrap(body) if unwrap else body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def delete(path, *, action="Delete"):
|
|
63
|
+
resp = requests.delete(_url(path), headers=auth_headers(), timeout=TIMEOUT)
|
|
64
|
+
_check(resp, action)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from obris import routes
|
|
5
|
+
from obris.api.client import delete, get, patch, post, post_form
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def knowledge_detail(knowledge_id):
|
|
9
|
+
return get(routes.knowledge_detail(knowledge_id), action="Get knowledge")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def knowledge_delete(knowledge_id):
|
|
13
|
+
delete(routes.knowledge_detail(knowledge_id), action="Delete knowledge")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def knowledge_move(knowledge_id, topic_id):
|
|
17
|
+
return post(
|
|
18
|
+
routes.knowledge_move(knowledge_id),
|
|
19
|
+
json={"topic_id": topic_id},
|
|
20
|
+
action="Move knowledge",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def knowledge_add(topic_id, title, content, source_type="cli"):
|
|
25
|
+
return post(
|
|
26
|
+
routes.topic_knowledge(topic_id),
|
|
27
|
+
json={"title": title, "content": content, "source_type": source_type},
|
|
28
|
+
action="Create knowledge",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def knowledge_update(topic_id, item_id, *, title=None, content=None):
|
|
33
|
+
payload = {}
|
|
34
|
+
if title is not None:
|
|
35
|
+
payload["title"] = title
|
|
36
|
+
if content is not None:
|
|
37
|
+
payload["content"] = content
|
|
38
|
+
return patch(
|
|
39
|
+
routes.topic_knowledge_item(topic_id, item_id),
|
|
40
|
+
json=payload,
|
|
41
|
+
action="Update knowledge",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def knowledge_replace_file(item_id, filepath):
|
|
46
|
+
filename = Path(filepath).name
|
|
47
|
+
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
48
|
+
with open(filepath, "rb") as f:
|
|
49
|
+
return post_form(
|
|
50
|
+
routes.knowledge_replace_file(item_id),
|
|
51
|
+
files={"file": (filename, f, mime_type)},
|
|
52
|
+
action="Replace file",
|
|
53
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from obris import routes
|
|
2
|
+
from obris.api.client import get, post
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def list_topics(*, name=None, is_system=None):
|
|
6
|
+
params = {}
|
|
7
|
+
if name is not None:
|
|
8
|
+
params["name"] = name
|
|
9
|
+
if is_system is not None:
|
|
10
|
+
params["is_system"] = str(is_system).lower()
|
|
11
|
+
return get(routes.topics(), params=params, action="List topics", unwrap=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_topic(topic_id):
|
|
15
|
+
return get(routes.topic(topic_id), action="Get topic")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_topic(name):
|
|
19
|
+
return post(routes.topics(), json={"name": name}, action="Create topic")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_all_topics(**kwargs):
|
|
23
|
+
"""Fetch all topics, handling pagination."""
|
|
24
|
+
items = []
|
|
25
|
+
page = 1
|
|
26
|
+
while True:
|
|
27
|
+
data = get(
|
|
28
|
+
routes.topics(),
|
|
29
|
+
params={**kwargs, "page": page, "page_size": 100},
|
|
30
|
+
action="List topics",
|
|
31
|
+
)
|
|
32
|
+
if isinstance(data, dict) and "results" in data:
|
|
33
|
+
items.extend(data["results"])
|
|
34
|
+
if not data.get("next"):
|
|
35
|
+
break
|
|
36
|
+
else:
|
|
37
|
+
items.extend(data if isinstance(data, list) else [])
|
|
38
|
+
break
|
|
39
|
+
page += 1
|
|
40
|
+
return items
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_knowledge(topic_id):
|
|
44
|
+
return get(routes.topic_knowledge(topic_id), action="List knowledge", unwrap=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_all_knowledge(topic_id):
|
|
48
|
+
"""Fetch all knowledge items for a topic, handling pagination."""
|
|
49
|
+
return list(iter_knowledge(topic_id))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def iter_knowledge(topic_id):
|
|
53
|
+
"""Yield knowledge items for a topic, page by page."""
|
|
54
|
+
page = 1
|
|
55
|
+
while True:
|
|
56
|
+
data = get(
|
|
57
|
+
routes.topic_knowledge(topic_id),
|
|
58
|
+
params={"page": page, "page_size": 100},
|
|
59
|
+
action="List knowledge",
|
|
60
|
+
)
|
|
61
|
+
if isinstance(data, dict) and "results" in data:
|
|
62
|
+
yield from data["results"]
|
|
63
|
+
if not data.get("next"):
|
|
64
|
+
break
|
|
65
|
+
else:
|
|
66
|
+
items = data if isinstance(data, list) else []
|
|
67
|
+
yield from items
|
|
68
|
+
break
|
|
69
|
+
page += 1
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
3
|
from obris import __version__, config, output
|
|
4
|
+
from obris.api.client import ApiError
|
|
4
5
|
from obris.commands.auth import auth
|
|
5
6
|
from obris.commands.env import env_group
|
|
6
7
|
from obris.commands.knowledge import knowledge_group
|
|
7
8
|
from obris.commands.save import save
|
|
9
|
+
from obris.commands.sync import sync
|
|
8
10
|
from obris.commands.topic import topic_group
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
class ObrisCLI(click.Group):
|
|
14
|
+
def invoke(self, ctx):
|
|
15
|
+
try:
|
|
16
|
+
return super().invoke(ctx)
|
|
17
|
+
except ApiError as e:
|
|
18
|
+
raise SystemExit(str(e)) from None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group(cls=ObrisCLI)
|
|
12
22
|
@click.version_option(__version__, prog_name="obris")
|
|
13
23
|
@click.option("--env", default=None, help="Environment override (default: prod)")
|
|
14
24
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
@@ -21,6 +31,7 @@ def cli(env, json_output):
|
|
|
21
31
|
|
|
22
32
|
cli.add_command(auth)
|
|
23
33
|
cli.add_command(save)
|
|
34
|
+
cli.add_command(sync)
|
|
24
35
|
cli.add_command(env_group)
|
|
25
36
|
cli.add_command(topic_group)
|
|
26
37
|
cli.add_command(knowledge_group)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import time
|
|
3
|
+
import webbrowser
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from obris import config, routes
|
|
9
|
+
from obris.api.client import ApiError
|
|
10
|
+
from obris.api.topics import list_topics
|
|
11
|
+
from obris.output import as_json, is_json
|
|
12
|
+
|
|
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
|
|
18
|
+
POLL_MAX_BACKOFF = 30
|
|
19
|
+
|
|
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
|
+
|
|
36
|
+
try:
|
|
37
|
+
resp = requests.post(f"{api_base}/{routes.device_sessions()}", timeout=10)
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
payload = resp.json()
|
|
40
|
+
session_id = payload["session_id"]
|
|
41
|
+
except requests.RequestException as e:
|
|
42
|
+
raise SystemExit(f"Failed to start login session: {e}") from e
|
|
43
|
+
except (ValueError, KeyError) as e:
|
|
44
|
+
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
|
+
|
|
51
|
+
with contextlib.suppress(webbrowser.Error):
|
|
52
|
+
webbrowser.open(url)
|
|
53
|
+
|
|
54
|
+
if not is_json():
|
|
55
|
+
click.echo("Waiting for authentication...")
|
|
56
|
+
|
|
57
|
+
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.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Helpers
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _poll_for_completion(api_base, session_id):
|
|
153
|
+
"""Poll the session endpoint until completed or expired.
|
|
154
|
+
|
|
155
|
+
Backs off on server errors (5xx, 429) with exponential delay,
|
|
156
|
+
logging each retry. Returns the completed session data.
|
|
157
|
+
"""
|
|
158
|
+
deadline = time.time() + POLL_TIMEOUT
|
|
159
|
+
poll_url = f"{api_base}/{routes.device_session(session_id)}"
|
|
160
|
+
backoff = POLL_INTERVAL
|
|
161
|
+
last_server_error = None
|
|
162
|
+
|
|
163
|
+
while True:
|
|
164
|
+
remaining = deadline - time.time()
|
|
165
|
+
if remaining <= 0:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
resp = requests.get(poll_url, timeout=10)
|
|
170
|
+
except requests.RequestException as e:
|
|
171
|
+
last_server_error = str(e)
|
|
172
|
+
click.echo(f" Network error polling session: {e}, retrying", err=True)
|
|
173
|
+
_sleep_within(backoff, deadline)
|
|
174
|
+
backoff = min(backoff * 2, POLL_MAX_BACKOFF)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if resp.status_code == 404:
|
|
178
|
+
raise SystemExit("Login session expired. Run 'obris auth login' to try again.")
|
|
179
|
+
|
|
180
|
+
if resp.status_code >= 500 or resp.status_code == 429:
|
|
181
|
+
last_server_error = f"HTTP {resp.status_code}"
|
|
182
|
+
click.echo(f" Server returned {resp.status_code}, retrying", err=True)
|
|
183
|
+
_sleep_within(backoff, deadline)
|
|
184
|
+
backoff = min(backoff * 2, POLL_MAX_BACKOFF)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if not resp.ok:
|
|
188
|
+
raise SystemExit(f"Login failed ({resp.status_code}): {resp.text}")
|
|
189
|
+
|
|
190
|
+
data = resp.json()
|
|
191
|
+
if data.get("status") == "completed":
|
|
192
|
+
if not data.get("access_token"):
|
|
193
|
+
raise SystemExit("Session completed but no token received.")
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
# Still pending — reset backoff and wait normally
|
|
197
|
+
last_server_error = None
|
|
198
|
+
backoff = POLL_INTERVAL
|
|
199
|
+
_sleep_within(POLL_INTERVAL, deadline)
|
|
200
|
+
|
|
201
|
+
if last_server_error:
|
|
202
|
+
raise SystemExit(f"Login polling failed after retries: {last_server_error}")
|
|
203
|
+
raise SystemExit("Login timed out. Run 'obris auth login' to try again.")
|
|
204
|
+
|
|
205
|
+
|
|
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:
|
|
210
|
+
return
|
|
211
|
+
time.sleep(min(seconds, remaining))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _detect_scratch():
|
|
215
|
+
"""Detect and store the Scratch topic ID. Returns the ID or None."""
|
|
216
|
+
env = config.get_active_env()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
results = list_topics(name="Scratch", is_system=True)
|
|
220
|
+
except ApiError as e:
|
|
221
|
+
click.echo(f"[{env}] Warning: could not detect Scratch topic: {e}", err=True)
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
scratch_id = results[0]["id"] if results else None
|
|
225
|
+
if scratch_id:
|
|
226
|
+
cfg = config.load()
|
|
227
|
+
cfg.setdefault(env, {})
|
|
228
|
+
cfg[env][config.KEY_SCRATCH_TOPIC] = scratch_id
|
|
229
|
+
config.save(cfg)
|
|
230
|
+
return scratch_id
|
|
@@ -57,21 +57,18 @@ def env_use(name):
|
|
|
57
57
|
@click.argument("name")
|
|
58
58
|
@click.option("--url", required=True, help="Base URL (e.g. https://obris.example.com)")
|
|
59
59
|
def env_add(name, url):
|
|
60
|
-
"""Add a custom environment
|
|
60
|
+
"""Add a custom environment."""
|
|
61
61
|
config.add_environment(name, api_base=url, app_base=url)
|
|
62
62
|
|
|
63
|
-
key = click.prompt("API key", hide_input=True)
|
|
64
63
|
cfg = config.load()
|
|
65
|
-
cfg.setdefault(name, {})
|
|
66
|
-
cfg[name][config.KEY_API_KEY] = key
|
|
67
|
-
|
|
68
64
|
if click.confirm(f"Set '{name}' as default environment?", default=True):
|
|
69
65
|
cfg[config.KEY_DEFAULT_ENV] = name
|
|
66
|
+
config.save(cfg)
|
|
70
67
|
|
|
71
|
-
config.save(cfg)
|
|
72
68
|
if is_json():
|
|
73
69
|
return as_json({"env": name, config.KEY_API_BASE: url})
|
|
74
70
|
click.echo(f"Added environment '{name}' -> {url}")
|
|
71
|
+
click.echo(f"Run 'obris --env {name} auth login' to authenticate.")
|
|
75
72
|
|
|
76
73
|
|
|
77
74
|
@env_group.command("remove")
|
|
@@ -11,8 +11,9 @@ from obris.utils import capture, notify, upload
|
|
|
11
11
|
@click.argument("filepath", required=False, type=click.Path(exists=True, path_type=Path))
|
|
12
12
|
@click.option("--screenshot", is_flag=True, help="Take a screenshot and upload")
|
|
13
13
|
@click.option("--name", default=None, help="Display name")
|
|
14
|
+
@click.option("--prompt", "prompt_name", is_flag=True, help="Prompt for a name via dialog")
|
|
14
15
|
@click.option("--topic", default=None, help="Topic ID (defaults to Scratch)")
|
|
15
|
-
def save(filepath, screenshot, name, topic):
|
|
16
|
+
def save(filepath, screenshot, name, prompt_name, topic):
|
|
16
17
|
"""Save a file or screenshot to a topic. Screenshots require macOS or Linux."""
|
|
17
18
|
topic_id = topic or config.get_scratch_topic_id()
|
|
18
19
|
|
|
@@ -22,6 +23,10 @@ def save(filepath, screenshot, name, topic):
|
|
|
22
23
|
except SystemExit:
|
|
23
24
|
notify.send("Obris", "Screenshot cancelled")
|
|
24
25
|
raise
|
|
26
|
+
if prompt_name:
|
|
27
|
+
name = capture.prompt_name()
|
|
28
|
+
if not name:
|
|
29
|
+
raise SystemExit("Name is required.")
|
|
25
30
|
name = name or path.stem
|
|
26
31
|
notify.send_quiet("Obris", "Uploading...")
|
|
27
32
|
try:
|