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.
Files changed (41) hide show
  1. obris_cli-0.4.0/.claude/settings.local.json +8 -0
  2. {obris_cli-0.2.0 → obris_cli-0.4.0}/.gitignore +3 -0
  3. {obris_cli-0.2.0 → obris_cli-0.4.0}/PKG-INFO +1 -1
  4. {obris_cli-0.2.0 → obris_cli-0.4.0}/README.md +11 -6
  5. {obris_cli-0.2.0 → obris_cli-0.4.0}/pyproject.toml +1 -1
  6. obris_cli-0.4.0/src/obris/api/__init__.py +30 -0
  7. obris_cli-0.4.0/src/obris/api/client.py +64 -0
  8. obris_cli-0.4.0/src/obris/api/knowledge.py +53 -0
  9. obris_cli-0.4.0/src/obris/api/topics.py +69 -0
  10. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/cli.py +12 -1
  11. obris_cli-0.4.0/src/obris/commands/auth.py +230 -0
  12. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/env.py +3 -6
  13. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/save.py +6 -1
  14. obris_cli-0.4.0/src/obris/commands/sync.py +180 -0
  15. obris_cli-0.4.0/src/obris/config.py +230 -0
  16. obris_cli-0.4.0/src/obris/routes.py +45 -0
  17. obris_cli-0.4.0/src/obris/sync/commands.py +61 -0
  18. obris_cli-0.4.0/src/obris/sync/constants.py +5 -0
  19. obris_cli-0.4.0/src/obris/sync/engine.py +253 -0
  20. obris_cli-0.4.0/src/obris/sync/io.py +50 -0
  21. obris_cli-0.4.0/src/obris/sync/mapping.py +120 -0
  22. obris_cli-0.4.0/src/obris/sync/models.py +75 -0
  23. obris_cli-0.4.0/src/obris/sync/state.py +126 -0
  24. obris_cli-0.4.0/src/obris/utils/__init__.py +0 -0
  25. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/upload.py +4 -2
  26. {obris_cli-0.2.0 → obris_cli-0.4.0}/uv.lock +1 -1
  27. obris_cli-0.2.0/src/obris/api/__init__.py +0 -14
  28. obris_cli-0.2.0/src/obris/api/client.py +0 -47
  29. obris_cli-0.2.0/src/obris/api/knowledge.py +0 -17
  30. obris_cli-0.2.0/src/obris/api/topics.py +0 -14
  31. obris_cli-0.2.0/src/obris/commands/auth.py +0 -54
  32. obris_cli-0.2.0/src/obris/config.py +0 -123
  33. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/__init__.py +0 -0
  34. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/assets/icon.png +0 -0
  35. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/__init__.py +0 -0
  36. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/knowledge.py +0 -0
  37. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/commands/topic.py +0 -0
  38. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/output.py +0 -0
  39. {obris_cli-0.2.0/src/obris/utils → obris_cli-0.4.0/src/obris/sync}/__init__.py +0 -0
  40. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/capture.py +0 -0
  41. {obris_cli-0.2.0 → obris_cli-0.4.0}/src/obris/utils/notify.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//Users/joshpanka/Repos/obris/**)",
5
+ "Bash(gh pr:*)"
6
+ ]
7
+ }
8
+ }
@@ -221,3 +221,6 @@ cython_debug/
221
221
  marimo/_static/
222
222
  marimo/_lsp/
223
223
  __marimo__/
224
+
225
+ # MCP build artifacts
226
+ *.mcpb
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obris-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
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
@@ -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
- You'll be prompted for your API key (or pass it directly with `--key`). Get your key from [app.obris.ai/api-keys](https://app.obris.ai/api-keys). Connects to Obris Cloud by default. See [Selfhosted](#selfhosted) for your own instance.
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 with an API key |
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. You'll be prompted for an API key:
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obris-cli"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -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
- @click.group()
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 and authenticate."""
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: