scholarinboxcli 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
scholarinboxcli/cli.py CHANGED
@@ -1,15 +1,12 @@
1
- """Scholar Inbox CLI."""
1
+ """Scholar Inbox CLI app composition."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
- from typing import Optional
7
-
8
5
  import typer
9
6
 
10
- from scholarinboxcli.api.client import ApiError, ScholarInboxClient
11
- from scholarinboxcli.formatters.json_fmt import format_json
12
- from scholarinboxcli.formatters.table import format_table
7
+ from scholarinboxcli.commands import auth, bookmarks, collections, conferences, papers
8
+ from scholarinboxcli.services.collections import resolve_collection_id as _resolve_collection_id # noqa: F401
9
+
13
10
 
14
11
  app = typer.Typer(
15
12
  help=(
@@ -23,502 +20,11 @@ app = typer.Typer(
23
20
  )
24
21
  )
25
22
 
23
+ # Top-level feed/search commands
24
+ papers.register(app)
26
25
 
27
- auth_app = typer.Typer(help="Authentication commands", no_args_is_help=True)
28
- collection_app = typer.Typer(help="Collection commands", no_args_is_help=True)
29
- bookmark_app = typer.Typer(help="Bookmark commands", no_args_is_help=True)
30
- conference_app = typer.Typer(help="Conference commands", no_args_is_help=True)
31
-
32
- app.add_typer(auth_app, name="auth")
33
- app.add_typer(collection_app, name="collection")
34
- app.add_typer(bookmark_app, name="bookmark")
35
- app.add_typer(conference_app, name="conference")
36
-
37
-
38
- def _print_output(data, use_json: bool, title: str | None = None) -> None:
39
- if use_json or not sys.stdout.isatty():
40
- typer.echo(format_json(data))
41
- return
42
- table = format_table(data, title=title)
43
- if table == "(no results)":
44
- typer.echo(table)
45
- return
46
- typer.echo(table)
47
-
48
-
49
- def _handle_error(err: ApiError) -> None:
50
- if not sys.stdout.isatty():
51
- typer.echo(format_json({"error": err.message, "status_code": err.status_code, "detail": err.detail}))
52
- else:
53
- typer.echo(f"Error: {err.message}", err=True)
54
- if err.status_code:
55
- typer.echo(f"Status: {err.status_code}", err=True)
56
- raise typer.Exit(1)
57
-
58
-
59
- def _normalize_name(name: str) -> str:
60
- return name.strip().lower()
61
-
62
-
63
- def _resolve_collection_id(client: ScholarInboxClient, identifier: str) -> str:
64
- if identifier.isdigit():
65
- return identifier
66
- data = client.collections_list()
67
- items = _collection_items_from_response(data)
68
- candidates = _collection_candidates(items)
69
- if not _candidates_have_ids(candidates):
70
- try:
71
- data = client.collections_expanded()
72
- items = _collection_items_from_response(data)
73
- candidates = _collection_candidates(items)
74
- except ApiError:
75
- pass
76
- if not _candidates_have_ids(candidates):
77
- try:
78
- data = client.collections_map()
79
- mapped = _collection_candidates_from_map(data)
80
- if mapped:
81
- candidates = mapped
82
- except ApiError:
83
- pass
84
- if not _candidates_have_ids(candidates):
85
- matched = _match_collection_name(candidates, identifier)
86
- if matched:
87
- # Only names are available; fall back to name as identifier.
88
- return matched
89
- raise ApiError("Unable to resolve collection name (no IDs available)")
90
- candidates = [(name, cid) for name, cid in candidates if cid]
91
- target = _normalize_name(identifier)
92
- for name, cid in candidates:
93
- if _normalize_name(name) == target:
94
- return cid
95
- # prefix match
96
- prefix = [c for c in candidates if _normalize_name(c[0]).startswith(target)]
97
- if len(prefix) == 1:
98
- return prefix[0][1]
99
- if len(prefix) > 1:
100
- names = ", ".join([f"{n}({cid})" for n, cid in prefix[:10]])
101
- raise ApiError(f"Ambiguous collection name. Matches: {names}")
102
- # contains match
103
- contains = [c for c in candidates if target in _normalize_name(c[0])]
104
- if len(contains) == 1:
105
- return contains[0][1]
106
- if len(contains) > 1:
107
- names = ", ".join([f"{n}({cid})" for n, cid in contains[:10]])
108
- raise ApiError(f"Ambiguous collection name. Matches: {names}")
109
- raise ApiError("Collection name not found")
110
-
111
-
112
- def _collection_candidates(items: object) -> list[tuple[str, str]]:
113
- if not isinstance(items, list):
114
- return []
115
- candidates: list[tuple[str, str]] = []
116
- for item in items:
117
- if isinstance(item, dict):
118
- name = item.get("name") or item.get("collection_name") or ""
119
- cid = str(item.get("id") or item.get("collection_id") or "")
120
- elif isinstance(item, str):
121
- name = item
122
- cid = ""
123
- else:
124
- continue
125
- if name:
126
- candidates.append((name, cid))
127
- return candidates
128
-
129
-
130
- def _collection_items_from_response(data: object) -> object:
131
- if isinstance(data, dict):
132
- for key in ("collections", "expanded_collections", "collection_names"):
133
- if key in data:
134
- return data.get(key)
135
- return data
136
- return data
137
-
138
-
139
- def _collection_candidates_from_map(data: object) -> list[tuple[str, str]]:
140
- if not isinstance(data, dict):
141
- return []
142
- mapping = data.get("collection_names_to_ids_dict")
143
- if not isinstance(mapping, dict):
144
- return []
145
- candidates: list[tuple[str, str]] = []
146
- for name, cid in mapping.items():
147
- if name and cid is not None:
148
- candidates.append((str(name), str(cid)))
149
- return candidates
150
-
151
-
152
- def _candidates_have_ids(candidates: list[tuple[str, str]]) -> bool:
153
- for _, cid in candidates:
154
- if cid:
155
- return True
156
- return False
157
-
158
-
159
- def _match_collection_name(candidates: list[tuple[str, str]], identifier: str) -> str | None:
160
- target = _normalize_name(identifier)
161
- names = [(name, cid) for name, cid in candidates if name]
162
- for name, _ in names:
163
- if _normalize_name(name) == target:
164
- return name
165
- prefix = [c for c in names if _normalize_name(c[0]).startswith(target)]
166
- if len(prefix) == 1:
167
- return prefix[0][0]
168
- if len(prefix) > 1:
169
- names_str = ", ".join([n for n, _ in prefix[:10]])
170
- raise ApiError(f"Ambiguous collection name. Matches: {names_str}")
171
- contains = [c for c in names if target in _normalize_name(c[0])]
172
- if len(contains) == 1:
173
- return contains[0][0]
174
- if len(contains) > 1:
175
- names_str = ", ".join([n for n, _ in contains[:10]])
176
- raise ApiError(f"Ambiguous collection name. Matches: {names_str}")
177
- return None
178
-
179
-
180
- @auth_app.command("login")
181
- def auth_login(
182
- url: str = typer.Option(..., "--url", help="Magic login URL with sha_key"),
183
- ):
184
- client = ScholarInboxClient()
185
- try:
186
- client.login_with_magic_link(url)
187
- typer.echo("Login successful")
188
- except ApiError as e:
189
- _handle_error(e)
190
- finally:
191
- client.close()
192
-
193
-
194
- @auth_app.command("status")
195
- def auth_status(json_output: bool = typer.Option(False, "--json", help="Output as JSON")):
196
- client = ScholarInboxClient()
197
- try:
198
- data = client.session_info()
199
- _print_output(data, json_output, title="Session")
200
- except ApiError as e:
201
- _handle_error(e)
202
- finally:
203
- client.close()
204
-
205
-
206
- @auth_app.command("logout")
207
- def auth_logout():
208
- from scholarinboxcli.config import save_config, Config
209
-
210
- save_config(Config())
211
- typer.echo("Logged out")
212
-
213
-
214
- @app.command("digest")
215
- def digest(
216
- date: Optional[str] = typer.Option(None, "--date", help="Digest date (MM-DD-YYYY)"),
217
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
218
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
219
- ):
220
- client = ScholarInboxClient(no_retry=no_retry)
221
- try:
222
- data = client.get_digest(date)
223
- _print_output(data, json_output, title="Digest")
224
- except ApiError as e:
225
- _handle_error(e)
226
- finally:
227
- client.close()
228
-
229
-
230
- @app.command("trending")
231
- def trending(
232
- category: str = typer.Option("ALL", "--category", help="Category filter"),
233
- days: int = typer.Option(7, "--days", help="Lookback window in days"),
234
- sort: str = typer.Option("hype", "--sort", help="Sort column"),
235
- asc: bool = typer.Option(False, "--asc", help="Sort ascending"),
236
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
237
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
238
- ):
239
- client = ScholarInboxClient(no_retry=no_retry)
240
- try:
241
- data = client.get_trending(category=category, days=days, sort=sort, asc=asc)
242
- _print_output(data, json_output, title="Trending")
243
- except ApiError as e:
244
- _handle_error(e)
245
- finally:
246
- client.close()
247
-
248
-
249
- @app.command("search")
250
- def search(
251
- query: str = typer.Argument(..., help="Search query"),
252
- sort: Optional[str] = typer.Option(None, "--sort", help="Sort option"),
253
- limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Limit results"),
254
- offset: Optional[int] = typer.Option(None, "--offset", help="Pagination offset"),
255
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
256
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
257
- ):
258
- client = ScholarInboxClient(no_retry=no_retry)
259
- try:
260
- data = client.search(query=query, sort=sort, limit=limit, offset=offset)
261
- _print_output(data, json_output, title="Search")
262
- except ApiError as e:
263
- _handle_error(e)
264
- finally:
265
- client.close()
266
-
267
-
268
- @app.command("semantic")
269
- def semantic_search(
270
- text: Optional[str] = typer.Argument(None, help="Semantic search text"),
271
- file: Optional[str] = typer.Option(None, "--file", help="Read query text from file"),
272
- limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Limit results"),
273
- offset: Optional[int] = typer.Option(None, "--offset", help="Pagination offset"),
274
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
275
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
276
- ):
277
- if not text and not file:
278
- typer.echo("Provide text or --file", err=True)
279
- raise typer.Exit(1)
280
- if file:
281
- text = open(file, "r", encoding="utf-8").read()
282
- client = ScholarInboxClient(no_retry=no_retry)
283
- try:
284
- data = client.semantic_search(text=text or "", limit=limit, offset=offset)
285
- _print_output(data, json_output, title="Semantic Search")
286
- except ApiError as e:
287
- _handle_error(e)
288
- finally:
289
- client.close()
290
-
291
-
292
- @app.command("interactions")
293
- def interactions(
294
- type_: str = typer.Option("all", "--type", help="Interaction type (all/up/down)"),
295
- sort: str = typer.Option("ranking_score", "--sort", help="Sort column"),
296
- asc: bool = typer.Option(False, "--asc", help="Sort ascending"),
297
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
298
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
299
- ):
300
- client = ScholarInboxClient(no_retry=no_retry)
301
- try:
302
- data = client.interactions(type_=type_, sort=sort, asc=asc)
303
- _print_output(data, json_output, title="Interactions")
304
- except ApiError as e:
305
- _handle_error(e)
306
- finally:
307
- client.close()
308
-
309
-
310
- @bookmark_app.command("list")
311
- def bookmark_list(
312
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
313
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
314
- ):
315
- client = ScholarInboxClient(no_retry=no_retry)
316
- try:
317
- data = client.bookmarks()
318
- _print_output(data, json_output, title="Bookmarks")
319
- except ApiError as e:
320
- _handle_error(e)
321
- finally:
322
- client.close()
323
-
324
-
325
- @bookmark_app.command("add")
326
- def bookmark_add(
327
- paper_id: str = typer.Argument(..., help="Paper ID"),
328
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
329
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
330
- ):
331
- client = ScholarInboxClient(no_retry=no_retry)
332
- try:
333
- data = client.bookmark_add(paper_id)
334
- _print_output(data, json_output, title="Bookmark added")
335
- except ApiError as e:
336
- _handle_error(e)
337
- finally:
338
- client.close()
339
-
340
-
341
- @bookmark_app.command("remove")
342
- def bookmark_remove(
343
- paper_id: str = typer.Argument(..., help="Paper ID"),
344
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
345
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
346
- ):
347
- client = ScholarInboxClient(no_retry=no_retry)
348
- try:
349
- data = client.bookmark_remove(paper_id)
350
- _print_output(data, json_output, title="Bookmark removed")
351
- except ApiError as e:
352
- _handle_error(e)
353
- finally:
354
- client.close()
355
-
356
-
357
- @collection_app.command("list")
358
- def collection_list(
359
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
360
- expanded: bool = typer.Option(False, "--expanded", help="Use expanded collection metadata"),
361
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
362
- ):
363
- client = ScholarInboxClient(no_retry=no_retry)
364
- try:
365
- data = client.collections_expanded() if expanded else client.collections_list()
366
- _print_output(data, json_output, title="Collections")
367
- except ApiError as e:
368
- _handle_error(e)
369
- finally:
370
- client.close()
371
-
372
- @collection_app.command("create")
373
- def collection_create(
374
- name: str = typer.Argument(..., help="Collection name"),
375
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
376
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
377
- ):
378
- client = ScholarInboxClient(no_retry=no_retry)
379
- try:
380
- data = client.collection_create(name)
381
- _print_output(data, json_output, title="Collection created")
382
- except ApiError as e:
383
- _handle_error(e)
384
- finally:
385
- client.close()
386
-
387
-
388
- @collection_app.command("rename")
389
- def collection_rename(
390
- collection_id: str = typer.Argument(..., help="Collection ID or name"),
391
- new_name: str = typer.Argument(..., help="New collection name"),
392
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
393
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
394
- ):
395
- client = ScholarInboxClient(no_retry=no_retry)
396
- try:
397
- cid = _resolve_collection_id(client, collection_id)
398
- data = client.collection_rename(cid, new_name)
399
- _print_output(data, json_output, title="Collection renamed")
400
- except ApiError as e:
401
- _handle_error(e)
402
- finally:
403
- client.close()
404
-
405
-
406
- @collection_app.command("delete")
407
- def collection_delete(
408
- collection_id: str = typer.Argument(..., help="Collection ID or name"),
409
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
410
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
411
- ):
412
- client = ScholarInboxClient(no_retry=no_retry)
413
- try:
414
- cid = _resolve_collection_id(client, collection_id)
415
- data = client.collection_delete(cid)
416
- _print_output(data, json_output, title="Collection deleted")
417
- except ApiError as e:
418
- _handle_error(e)
419
- finally:
420
- client.close()
421
-
422
-
423
- @collection_app.command("add")
424
- def collection_add(
425
- collection_id: str = typer.Argument(..., help="Collection ID or name"),
426
- paper_id: str = typer.Argument(..., help="Paper ID"),
427
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
428
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
429
- ):
430
- client = ScholarInboxClient(no_retry=no_retry)
431
- try:
432
- cid = _resolve_collection_id(client, collection_id)
433
- data = client.collection_add_paper(cid, paper_id)
434
- _print_output(data, json_output, title="Collection add paper")
435
- except ApiError as e:
436
- _handle_error(e)
437
- finally:
438
- client.close()
439
-
440
-
441
- @collection_app.command("remove")
442
- def collection_remove(
443
- collection_id: str = typer.Argument(..., help="Collection ID or name"),
444
- paper_id: str = typer.Argument(..., help="Paper ID"),
445
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
446
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
447
- ):
448
- client = ScholarInboxClient(no_retry=no_retry)
449
- try:
450
- cid = _resolve_collection_id(client, collection_id)
451
- data = client.collection_remove_paper(cid, paper_id)
452
- _print_output(data, json_output, title="Collection remove paper")
453
- except ApiError as e:
454
- _handle_error(e)
455
- finally:
456
- client.close()
457
-
458
-
459
- @collection_app.command("papers")
460
- def collection_papers(
461
- collection_id: str = typer.Argument(..., help="Collection ID or name"),
462
- limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Limit results"),
463
- offset: Optional[int] = typer.Option(None, "--offset", help="Pagination offset"),
464
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
465
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
466
- ):
467
- client = ScholarInboxClient(no_retry=no_retry)
468
- try:
469
- cid = _resolve_collection_id(client, collection_id)
470
- data = client.collection_papers(cid, limit=limit, offset=offset)
471
- _print_output(data, json_output, title=f"Collection {cid}")
472
- except ApiError as e:
473
- _handle_error(e)
474
- finally:
475
- client.close()
476
-
477
-
478
- @collection_app.command("similar")
479
- def collection_similar(
480
- collection_ids: list[str] = typer.Argument(..., help="Collection ID(s) or names"),
481
- limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Limit results"),
482
- offset: Optional[int] = typer.Option(None, "--offset", help="Pagination offset"),
483
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
484
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
485
- ):
486
- client = ScholarInboxClient(no_retry=no_retry)
487
- try:
488
- resolved = [_resolve_collection_id(client, cid) for cid in collection_ids]
489
- data = client.collections_similar(resolved, limit=limit, offset=offset)
490
- _print_output(data, json_output, title="Similar Papers")
491
- except ApiError as e:
492
- _handle_error(e)
493
- finally:
494
- client.close()
495
-
496
-
497
- @conference_app.command("list")
498
- def conference_list(
499
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
500
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
501
- ):
502
- client = ScholarInboxClient(no_retry=no_retry)
503
- try:
504
- data = client.conference_list()
505
- _print_output(data, json_output, title="Conferences")
506
- except ApiError as e:
507
- _handle_error(e)
508
- finally:
509
- client.close()
510
-
511
-
512
- @conference_app.command("explore")
513
- def conference_explore(
514
- json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
515
- no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
516
- ):
517
- client = ScholarInboxClient(no_retry=no_retry)
518
- try:
519
- data = client.conference_explorer()
520
- _print_output(data, json_output, title="Conference Explorer")
521
- except ApiError as e:
522
- _handle_error(e)
523
- finally:
524
- client.close()
26
+ # Grouped commands
27
+ app.add_typer(auth.app, name="auth")
28
+ app.add_typer(collections.app, name="collection")
29
+ app.add_typer(bookmarks.app, name="bookmark")
30
+ app.add_typer(conferences.app, name="conference")
@@ -0,0 +1 @@
1
+ """Command groups for scholarinboxcli."""
@@ -0,0 +1,39 @@
1
+ """Authentication command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from scholarinboxcli.commands.common import print_output, with_client
8
+ from scholarinboxcli.formatters.domain_tables import format_auth_status
9
+
10
+
11
+ app = typer.Typer(help="Authentication commands", no_args_is_help=True)
12
+
13
+
14
+ @app.command("login")
15
+ def auth_login(
16
+ url: str = typer.Option(..., "--url", help="Magic login URL with sha_key"),
17
+ ):
18
+ def action(client):
19
+ client.login_with_magic_link(url)
20
+ typer.echo("Login successful")
21
+
22
+ with_client(False, action)
23
+
24
+
25
+ @app.command("status")
26
+ def auth_status(json_output: bool = typer.Option(False, "--json", help="Output as JSON")):
27
+ def action(client):
28
+ data = client.session_info()
29
+ print_output(data, json_output, title="Session", table_formatter=format_auth_status)
30
+
31
+ with_client(False, action)
32
+
33
+
34
+ @app.command("logout")
35
+ def auth_logout():
36
+ from scholarinboxcli.config import Config, save_config
37
+
38
+ save_config(Config())
39
+ typer.echo("Logged out")
@@ -0,0 +1,49 @@
1
+ """Bookmark command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from scholarinboxcli.commands.common import print_output, with_client
8
+ from scholarinboxcli.formatters.domain_tables import format_collection_papers
9
+
10
+
11
+ app = typer.Typer(help="Bookmark commands", no_args_is_help=True)
12
+
13
+
14
+ @app.command("list")
15
+ def bookmark_list(
16
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
17
+ no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
18
+ ):
19
+ def action(client):
20
+ data = client.bookmarks()
21
+ print_output(data, json_output, title="Bookmarks", table_formatter=format_collection_papers)
22
+
23
+ with_client(no_retry, action)
24
+
25
+
26
+ @app.command("add")
27
+ def bookmark_add(
28
+ paper_id: str = typer.Argument(..., help="Paper ID"),
29
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
30
+ no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
31
+ ):
32
+ def action(client):
33
+ data = client.bookmark_add(paper_id)
34
+ print_output(data, json_output, title="Bookmark added")
35
+
36
+ with_client(no_retry, action)
37
+
38
+
39
+ @app.command("remove")
40
+ def bookmark_remove(
41
+ paper_id: str = typer.Argument(..., help="Paper ID"),
42
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
43
+ no_retry: bool = typer.Option(False, "--no-retry", help="Disable retry on rate limits"),
44
+ ):
45
+ def action(client):
46
+ data = client.bookmark_remove(paper_id)
47
+ print_output(data, json_output, title="Bookmark removed")
48
+
49
+ with_client(no_retry, action)