bearcli 1.0.0__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.
bearcli/cli.py ADDED
@@ -0,0 +1,788 @@
1
+ """Typer CLI for reading notes from the Bear note app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ import sys
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime, timedelta
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+ from urllib.parse import quote
15
+
16
+ import typer
17
+ from rich import box
18
+ from rich.console import Console
19
+ from rich.markup import escape as rich_escape
20
+ from rich.table import Table
21
+
22
+ from bearcli import actions
23
+ from bearcli.db import DEFAULT_DB_PATH, BearDB, Note
24
+ from bearcli.export import export_notes
25
+ from bearcli.search import naive_search, search_notes
26
+
27
+ app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
28
+ note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
29
+ tag_app = typer.Typer(help="List and manage tags.", no_args_is_help=True)
30
+ app.add_typer(note_app, name="note")
31
+ app.add_typer(tag_app, name="tag")
32
+ console = Console()
33
+
34
+
35
+ class OutputFormat(StrEnum):
36
+ table = "table"
37
+ json = "json"
38
+ text = "text"
39
+
40
+
41
+ class OnlyFilter(StrEnum):
42
+ pinned = "pinned"
43
+ encrypted = "encrypted"
44
+ trashed = "trashed"
45
+ archived = "archived"
46
+
47
+
48
+ def _note_to_dict(note: Note, with_text: bool = False) -> dict:
49
+ data = {
50
+ "id": note.id,
51
+ "title": note.title,
52
+ "tags": note.tags,
53
+ "created": note.created.isoformat() if note.created else None,
54
+ "modified": note.modified.isoformat() if note.modified else None,
55
+ "pinned": note.pinned,
56
+ "encrypted": note.encrypted,
57
+ "archived": note.archived,
58
+ "trashed": note.trashed,
59
+ }
60
+ if with_text:
61
+ data["text"] = note.text
62
+ data["attachments"] = [
63
+ {
64
+ "filename": a.filename,
65
+ "path": str(a.path),
66
+ "size": a.size,
67
+ "exists": a.exists,
68
+ }
69
+ for a in note.attachments
70
+ ]
71
+ return data
72
+
73
+
74
+ def _note_status(note: Note) -> str:
75
+ flags = (
76
+ ("pinned", note.pinned),
77
+ ("encrypted", note.encrypted),
78
+ ("trashed", note.trashed),
79
+ ("archived", note.archived),
80
+ )
81
+ return ",".join(s for s, on in flags if on)
82
+
83
+
84
+ def _resolve_attachments(note: Note) -> str:
85
+ """Rewrite bare attachment filenames in markdown links to absolute paths.
86
+
87
+ Bear percent-encodes filenames in the note text (e.g. spaces as %20), so match
88
+ both the raw and encoded forms; emit encoded paths to keep the links valid.
89
+ """
90
+ text = note.text or ""
91
+ for att in note.attachments:
92
+ target = quote(str(att.path))
93
+ for ref in {att.filename, quote(att.filename)}:
94
+ text = text.replace(f"]({ref})", f"]({target})")
95
+ return text
96
+
97
+
98
+ DbPathOption = Annotated[
99
+ Path,
100
+ typer.Option("--db", envvar="BEAR_DB_PATH", help="Path to the Bear SQLite database."),
101
+ ]
102
+
103
+
104
+ def _open_db(path: Path) -> BearDB:
105
+ try:
106
+ return BearDB(path)
107
+ except FileNotFoundError as exc:
108
+ console.print(f"[red]Error:[/red] {exc}")
109
+ raise typer.Exit(1) from None
110
+
111
+
112
+ def _parse_date(value: str | None, option: str) -> datetime | None:
113
+ if value is None:
114
+ return None
115
+ try:
116
+ return datetime.fromisoformat(value)
117
+ except ValueError:
118
+ console.print(
119
+ f"[red]Error:[/red] invalid date for {option}: {value!r} "
120
+ "(expected ISO format, e.g. 2026-07-01 or 2026-07-01T14:30)"
121
+ )
122
+ raise typer.Exit(2) from None
123
+
124
+
125
+ @note_app.command("list")
126
+ def list_notes(
127
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum number of notes.")] = 20,
128
+ tag: Annotated[
129
+ str | None,
130
+ typer.Option("--tag", "-t", help="Only notes with this tag (includes nested sub-tags)."),
131
+ ] = None,
132
+ modified_after: Annotated[
133
+ str | None, typer.Option("--modified-after", help="Modified on or after this date.")
134
+ ] = None,
135
+ modified_before: Annotated[str | None, typer.Option("--modified-before", help="Modified before this date.")] = None,
136
+ created_after: Annotated[str | None, typer.Option("--created-after", help="Created on or after this date.")] = None,
137
+ created_before: Annotated[str | None, typer.Option("--created-before", help="Created before this date.")] = None,
138
+ only: Annotated[
139
+ OnlyFilter | None,
140
+ typer.Option(
141
+ "--only",
142
+ help="Only notes with this status: pinned, encrypted, trashed, or archived.",
143
+ ),
144
+ ] = None,
145
+ all_notes: Annotated[bool, typer.Option("--all", "-a", help="No limit (overrides --limit).")] = False,
146
+ trashed: Annotated[bool, typer.Option("--trashed", help="Include trashed notes.")] = False,
147
+ archived: Annotated[bool, typer.Option("--archived", help="Include archived notes.")] = False,
148
+ ids_only: Annotated[bool, typer.Option("--ids", help="Print only note identifiers, one per line.")] = False,
149
+ fmt: Annotated[
150
+ OutputFormat,
151
+ typer.Option(
152
+ "--format",
153
+ "-f",
154
+ help="Output format: table, json, or text (tab-separated: id, modified, tags, title).",
155
+ ),
156
+ ] = OutputFormat.table,
157
+ db_path: DbPathOption = DEFAULT_DB_PATH,
158
+ ) -> None:
159
+ """List notes, most recently modified first."""
160
+ db = _open_db(db_path)
161
+ try:
162
+ notes = db.list_notes(
163
+ limit=None if all_notes else limit,
164
+ tag=tag,
165
+ created_after=_parse_date(created_after, "--created-after"),
166
+ created_before=_parse_date(created_before, "--created-before"),
167
+ modified_after=_parse_date(modified_after, "--modified-after"),
168
+ modified_before=_parse_date(modified_before, "--modified-before"),
169
+ only=only.value if only else None,
170
+ include_trashed=trashed,
171
+ include_archived=archived,
172
+ )
173
+ finally:
174
+ db.close()
175
+
176
+ if ids_only:
177
+ for note in notes:
178
+ print(note.id)
179
+ return
180
+
181
+ if fmt is OutputFormat.json:
182
+ print(json.dumps([_note_to_dict(n) for n in notes], indent=2, ensure_ascii=False))
183
+ return
184
+
185
+ if fmt is OutputFormat.text:
186
+ for note in notes:
187
+ modified = note.modified.isoformat() if note.modified else ""
188
+ print(f"{note.id}\t{modified}\t{','.join(note.tags)}\t{_note_status(note)}\t{note.title}")
189
+ return
190
+
191
+ if not notes:
192
+ console.print("No notes found.")
193
+ return
194
+
195
+ table = Table(box=box.ROUNDED, header_style="bold")
196
+ table.add_column("ID", style="dim", no_wrap=True)
197
+ table.add_column("Title", overflow="ellipsis", max_width=50)
198
+ table.add_column("Tags", style="cyan", overflow="ellipsis", max_width=30)
199
+ table.add_column("Status", style="yellow", no_wrap=True)
200
+ table.add_column("Modified", no_wrap=True)
201
+ for note in notes:
202
+ table.add_row(
203
+ note.id,
204
+ note.title,
205
+ ", ".join(note.tags),
206
+ _note_status(note),
207
+ note.modified.strftime("%Y-%m-%d %H:%M") if note.modified else "",
208
+ )
209
+ console.print(table)
210
+
211
+
212
+ @note_app.command()
213
+ def get(
214
+ note_id: Annotated[str, typer.Argument(help="Note identifier (UUID from `bearcli list`).")],
215
+ meta: Annotated[bool, typer.Option("--meta", help="Print metadata frontmatter before the content.")] = False,
216
+ resolve_attachments: Annotated[
217
+ bool,
218
+ typer.Option(
219
+ "--resolve-attachments",
220
+ "-r",
221
+ help="Rewrite attachment references in the content to absolute file paths.",
222
+ ),
223
+ ] = False,
224
+ fmt: Annotated[
225
+ OutputFormat,
226
+ typer.Option(
227
+ "--format",
228
+ "-f",
229
+ help="Output format: text (raw content), json (metadata + content), or table.",
230
+ ),
231
+ ] = OutputFormat.text,
232
+ db_path: DbPathOption = DEFAULT_DB_PATH,
233
+ ) -> None:
234
+ """Print the content of a note."""
235
+ db = _open_db(db_path)
236
+ try:
237
+ note = db.get_note(note_id)
238
+ finally:
239
+ db.close()
240
+
241
+ if note is None:
242
+ console.print(f"[red]Error:[/red] no note with id {note_id!r}")
243
+ raise typer.Exit(1)
244
+ if note.encrypted or note.text is None:
245
+ console.print(f"[red]Error:[/red] note {note.id} is encrypted; its content is unavailable")
246
+ raise typer.Exit(1)
247
+
248
+ text = _resolve_attachments(note) if resolve_attachments else note.text
249
+
250
+ if fmt is OutputFormat.json:
251
+ data = _note_to_dict(note, with_text=True)
252
+ data["text"] = text
253
+ print(json.dumps(data, indent=2, ensure_ascii=False))
254
+ return
255
+
256
+ if fmt is OutputFormat.table:
257
+ table = Table(box=box.ROUNDED, show_header=False)
258
+ table.add_column(style="bold")
259
+ table.add_column()
260
+ table.add_row("ID", note.id)
261
+ table.add_row("Title", note.title)
262
+ table.add_row("Tags", ", ".join(note.tags))
263
+ if note.created:
264
+ table.add_row("Created", note.created.strftime("%Y-%m-%d %H:%M"))
265
+ if note.modified:
266
+ table.add_row("Modified", note.modified.strftime("%Y-%m-%d %H:%M"))
267
+ for i, att in enumerate(note.attachments):
268
+ label = "Attachments" if i == 0 else ""
269
+ missing = "" if att.exists else " (missing)"
270
+ table.add_row(label, f"{att.path}{missing}")
271
+ console.print(table)
272
+ console.print()
273
+ print(text)
274
+ return
275
+
276
+ if meta:
277
+ print("---")
278
+ print(f"id: {note.id}")
279
+ print(f"title: {note.title}")
280
+ print(f"tags: [{', '.join(note.tags)}]")
281
+ if note.created:
282
+ print(f"created: {note.created.isoformat()}")
283
+ if note.modified:
284
+ print(f"modified: {note.modified.isoformat()}")
285
+ if note.attachments:
286
+ print(f"attachments: [{', '.join(str(a.path) for a in note.attachments)}]")
287
+ print("---")
288
+ print(text)
289
+
290
+
291
+ @app.command()
292
+ def export(
293
+ dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
294
+ sync: Annotated[
295
+ bool,
296
+ typer.Option(
297
+ "--sync",
298
+ help="Only rewrite notes that changed since the last export instead of everything.",
299
+ ),
300
+ ] = False,
301
+ db_path: DbPathOption = DEFAULT_DB_PATH,
302
+ ) -> None:
303
+ """Export all notes as markdown files with frontmatter and attachments."""
304
+ db = _open_db(db_path)
305
+ try:
306
+ with console.status("Exporting…", spinner="dots") as status:
307
+ result = export_notes(db, dest, sync=sync, progress=lambda msg: status.update(rich_escape(msg)))
308
+ finally:
309
+ db.close()
310
+
311
+ parts = [f"{result.written} written"]
312
+ if sync:
313
+ parts.append(f"{result.unchanged} unchanged")
314
+ if result.removed:
315
+ parts.append(f"{result.removed} removed")
316
+ if result.skipped_encrypted:
317
+ parts.append(f"{result.skipped_encrypted} encrypted skipped")
318
+ if result.index_updated:
319
+ parts.append("index updated")
320
+ console.print(f"Exported to {dest}: " + ", ".join(parts))
321
+ if result.index_skipped:
322
+ console.print("[yellow]Warning:[/yellow] README.md exists but was not generated by bearcli; left untouched")
323
+
324
+
325
+ def _text_or_stdin(text: str | None) -> str | None:
326
+ if text is None and not sys.stdin.isatty():
327
+ return sys.stdin.read()
328
+ return text
329
+
330
+
331
+ def _require_note(db: BearDB, note_id: str) -> Note:
332
+ note = db.get_note(note_id)
333
+ if note is None:
334
+ console.print(f"[red]Error:[/red] no note with id {note_id!r}")
335
+ raise typer.Exit(1)
336
+ return note
337
+
338
+
339
+ def _verify(ok: Callable[[], bool], success: str, failure: str) -> None:
340
+ if actions.wait_for(ok):
341
+ console.print(success)
342
+ else:
343
+ console.print(f"[red]Error:[/red] {failure}")
344
+ raise typer.Exit(1)
345
+
346
+
347
+ def _create_and_report(db: BearDB, title: str, text: str | None, tags: list[str] | None) -> None:
348
+ started = datetime.now(UTC)
349
+ actions.create_note(title, text=text, tags=tags)
350
+
351
+ def find_created() -> Note | None:
352
+ candidates = db.list_notes(limit=10)
353
+ return next(
354
+ (n for n in candidates if n.title == title and n.created and n.created >= started - timedelta(seconds=5)),
355
+ None,
356
+ )
357
+
358
+ if actions.wait_for(lambda: find_created() is not None):
359
+ created = find_created()
360
+ console.print(f"Created note {created.id}" if created else "Created note")
361
+ else:
362
+ console.print("[red]Error:[/red] note did not appear in the Bear database; is Bear able to run?")
363
+ raise typer.Exit(1)
364
+
365
+
366
+ @note_app.command()
367
+ def create(
368
+ title: Annotated[str, typer.Argument(help="Title of the new note.")],
369
+ text: Annotated[str | None, typer.Option("--text", help="Note body (reads stdin if piped).")] = None,
370
+ tags: Annotated[list[str] | None, typer.Option("--tag", "-t", help="Tag to add (repeatable).")] = None,
371
+ db_path: DbPathOption = DEFAULT_DB_PATH,
372
+ ) -> None:
373
+ """Create a new note in Bear."""
374
+ db = _open_db(db_path)
375
+ try:
376
+ _create_and_report(db, title, _text_or_stdin(text), tags)
377
+ finally:
378
+ db.close()
379
+
380
+
381
+ @note_app.command()
382
+ def append(
383
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
384
+ text: Annotated[str | None, typer.Option("--text", help="Text to add (reads stdin if piped).")] = None,
385
+ prepend: Annotated[bool, typer.Option("--prepend", help="Add at the top instead of the bottom.")] = False,
386
+ db_path: DbPathOption = DEFAULT_DB_PATH,
387
+ ) -> None:
388
+ """Append (or prepend) text to an existing note."""
389
+ body = _text_or_stdin(text)
390
+ if body is None:
391
+ console.print("[red]Error:[/red] provide --text or pipe content on stdin")
392
+ raise typer.Exit(2)
393
+ db = _open_db(db_path)
394
+ try:
395
+ before = _require_note(db, note_id)
396
+ actions.add_text(before.id, body, mode="prepend" if prepend else "append")
397
+ _verify(
398
+ lambda: (
399
+ (n := db.get_note(before.id)) is not None
400
+ and n.modified is not None
401
+ and (before.modified is None or n.modified > before.modified)
402
+ ),
403
+ f"Updated note {before.id}",
404
+ "note was not modified; is Bear able to run?",
405
+ )
406
+ finally:
407
+ db.close()
408
+
409
+
410
+ @note_app.command()
411
+ def trash(
412
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
413
+ db_path: DbPathOption = DEFAULT_DB_PATH,
414
+ ) -> None:
415
+ """Move a note to Bear's trash."""
416
+ db = _open_db(db_path)
417
+ try:
418
+ note = _require_note(db, note_id)
419
+ if note.trashed:
420
+ console.print(f"Note {note.id} is already in the trash")
421
+ return
422
+ actions.trash_note(note.id)
423
+ _verify(
424
+ lambda: (n := db.get_note(note.id)) is not None and n.trashed,
425
+ f"Trashed note {note.id}",
426
+ "note was not trashed; is Bear able to run?",
427
+ )
428
+ finally:
429
+ db.close()
430
+
431
+
432
+ @note_app.command()
433
+ def archive(
434
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
435
+ db_path: DbPathOption = DEFAULT_DB_PATH,
436
+ ) -> None:
437
+ """Archive a note."""
438
+ db = _open_db(db_path)
439
+ try:
440
+ note = _require_note(db, note_id)
441
+ if note.archived:
442
+ console.print(f"Note {note.id} is already archived")
443
+ return
444
+ actions.archive_note(note.id)
445
+ _verify(
446
+ lambda: (n := db.get_note(note.id)) is not None and n.archived,
447
+ f"Archived note {note.id}",
448
+ "note was not archived; is Bear able to run?",
449
+ )
450
+ finally:
451
+ db.close()
452
+
453
+
454
+ @tag_app.command("list")
455
+ def tags(
456
+ fmt: Annotated[
457
+ OutputFormat,
458
+ typer.Option("--format", "-f", help="Output format: table, json, or text (tab-separated: count, tag)."),
459
+ ] = OutputFormat.table,
460
+ include_empty: Annotated[
461
+ bool, typer.Option("--all", "-a", help="Include empty tags (Bear keeps them hidden after their last note).")
462
+ ] = False,
463
+ db_path: DbPathOption = DEFAULT_DB_PATH,
464
+ ) -> None:
465
+ """List all tags with their note counts."""
466
+ db = _open_db(db_path)
467
+ try:
468
+ all_tags = db.list_tags(include_empty=include_empty)
469
+ finally:
470
+ db.close()
471
+
472
+ if fmt is OutputFormat.json:
473
+ print(json.dumps([{"tag": t, "notes": c} for t, c in all_tags], indent=2, ensure_ascii=False))
474
+ return
475
+ if fmt is OutputFormat.text:
476
+ for t, c in all_tags:
477
+ print(f"{c}\t{t}")
478
+ return
479
+
480
+ table = Table(box=box.ROUNDED, header_style="bold")
481
+ table.add_column("Tag", style="cyan")
482
+ table.add_column("Notes", justify="right")
483
+ for t, c in all_tags:
484
+ table.add_row(t, str(c))
485
+ console.print(table)
486
+
487
+
488
+ def _tag_marker(name: str) -> str:
489
+ # Tags containing anything beyond word chars, '/', or '-' need the #...# form.
490
+ return f"#{name}#" if re.search(r"[^\w/-]", name) else f"#{name}"
491
+
492
+
493
+ def _has_tag(note: Note, name: str) -> bool:
494
+ return name.lower() in (t.lower() for t in note.tags)
495
+
496
+
497
+ @note_app.command()
498
+ def tag(
499
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
500
+ name: Annotated[str, typer.Argument(help="Tag to add (without the leading #).")],
501
+ db_path: DbPathOption = DEFAULT_DB_PATH,
502
+ ) -> None:
503
+ """Add a tag to a note."""
504
+ db = _open_db(db_path)
505
+ try:
506
+ note = _require_note(db, note_id)
507
+ if _has_tag(note, name):
508
+ console.print(f"Note {note.id} already has tag {name!r}")
509
+ return
510
+ actions.add_text(note.id, _tag_marker(name), mode="append")
511
+ _verify(
512
+ lambda: (n := db.get_note(note.id)) is not None and _has_tag(n, name),
513
+ f"Tagged note {note.id} with {name!r}",
514
+ "tag did not appear; is Bear able to run?",
515
+ )
516
+ finally:
517
+ db.close()
518
+
519
+
520
+ @note_app.command()
521
+ def untag(
522
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
523
+ name: Annotated[str, typer.Argument(help="Tag to remove (without the leading #).")],
524
+ db_path: DbPathOption = DEFAULT_DB_PATH,
525
+ ) -> None:
526
+ """Remove a tag from a note (rewrites the note text without the tag marker)."""
527
+ db = _open_db(db_path)
528
+ try:
529
+ note = _require_note(db, note_id)
530
+ if not _has_tag(note, name) or note.text is None:
531
+ console.print(
532
+ f"[red]Error:[/red] note {note.id} has no tag {name!r} (tags: {', '.join(note.tags) or 'none'})"
533
+ )
534
+ raise typer.Exit(1)
535
+ # Strip both marker forms; don't touch longer tags sharing the prefix
536
+ # (removing "work" must leave "#work/ideas" and "#workout" alone).
537
+ escaped = re.escape(name)
538
+ new_text = re.sub(rf"[ \t]?#{escaped}#", "", note.text, flags=re.IGNORECASE)
539
+ new_text = re.sub(rf"[ \t]?#{escaped}(?![\w/-])", "", new_text, flags=re.IGNORECASE)
540
+ if new_text == note.text:
541
+ console.print(f"[red]Error:[/red] could not locate the #{name} marker in the note text")
542
+ raise typer.Exit(1)
543
+ new_text = new_text.rstrip("\n") + "\n"
544
+ actions.add_text(note.id, new_text, mode="replace_all")
545
+ _verify(
546
+ lambda: (n := db.get_note(note.id)) is not None and not _has_tag(n, name),
547
+ f"Removed tag {name!r} from note {note.id}",
548
+ "tag was not removed; is Bear able to run?",
549
+ )
550
+ finally:
551
+ db.close()
552
+
553
+
554
+ @note_app.command("open")
555
+ def open_note(
556
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
557
+ new_window: Annotated[bool, typer.Option("--new-window", "-w", help="Open in a separate window.")] = False,
558
+ db_path: DbPathOption = DEFAULT_DB_PATH,
559
+ ) -> None:
560
+ """Open a note in the Bear app."""
561
+ db = _open_db(db_path)
562
+ try:
563
+ note = _require_note(db, note_id)
564
+ finally:
565
+ db.close()
566
+ actions.open_note(note.id, new_window=new_window)
567
+ console.print(f"Opened note {note.id} in Bear")
568
+
569
+
570
+ MAX_ATTACH_BYTES = 500_000 # the file travels base64-encoded inside a URL; macOS caps arg size at ~1 MB
571
+
572
+
573
+ @note_app.command()
574
+ def attach(
575
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
576
+ file: Annotated[Path, typer.Argument(help="File to attach (appended at the end of the note).")],
577
+ db_path: DbPathOption = DEFAULT_DB_PATH,
578
+ ) -> None:
579
+ """Attach a file to a note."""
580
+ if not file.is_file():
581
+ console.print(f"[red]Error:[/red] {file} is not a file")
582
+ raise typer.Exit(1)
583
+ data = file.read_bytes()
584
+ if len(data) > MAX_ATTACH_BYTES:
585
+ console.print(
586
+ f"[red]Error:[/red] {file.name} is {len(data)} bytes; attachments are limited to "
587
+ f"{MAX_ATTACH_BYTES} bytes (the file is passed base64-encoded through a URL)"
588
+ )
589
+ raise typer.Exit(1)
590
+ db = _open_db(db_path)
591
+ try:
592
+ note = _require_note(db, note_id)
593
+ before = len(note.attachments)
594
+ actions.add_file(note.id, file.name, base64.b64encode(data).decode())
595
+ _verify(
596
+ lambda: (n := db.get_note(note.id)) is not None and len(n.attachments) > before,
597
+ f"Attached {file.name} to note {note.id}",
598
+ "attachment did not appear; is Bear able to run?",
599
+ )
600
+ finally:
601
+ db.close()
602
+
603
+
604
+ @note_app.command()
605
+ def rename(
606
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
607
+ new_title: Annotated[str, typer.Argument(help="New title for the note.")],
608
+ db_path: DbPathOption = DEFAULT_DB_PATH,
609
+ ) -> None:
610
+ """Change a note's title (first line), keeping the body."""
611
+ db = _open_db(db_path)
612
+ try:
613
+ note = _require_note(db, note_id)
614
+ if note.text is None:
615
+ console.print(f"[red]Error:[/red] note {note.id} is encrypted; cannot rename")
616
+ raise typer.Exit(1)
617
+ head, sep, body = note.text.partition("\n")
618
+ if head.startswith("# "):
619
+ new_text = f"# {new_title}{sep}{body}"
620
+ else:
621
+ new_text = f"# {new_title}\n{note.text}"
622
+ actions.add_text(note.id, new_text, mode="replace_all")
623
+ _verify(
624
+ lambda: (n := db.get_note(note.id)) is not None and n.title == new_title,
625
+ f"Renamed note {note.id} to {new_title!r}",
626
+ "title did not change; is Bear able to run?",
627
+ )
628
+ finally:
629
+ db.close()
630
+
631
+
632
+ @note_app.command()
633
+ def replace(
634
+ note_id: Annotated[str, typer.Argument(help="Note identifier.")],
635
+ text: Annotated[str | None, typer.Option("--text", help="New body (reads stdin if piped).")] = None,
636
+ db_path: DbPathOption = DEFAULT_DB_PATH,
637
+ ) -> None:
638
+ """Replace a note's body with new text, keeping the title. Destructive."""
639
+ body = _text_or_stdin(text)
640
+ if body is None:
641
+ console.print("[red]Error:[/red] provide --text or pipe content on stdin")
642
+ raise typer.Exit(2)
643
+ db = _open_db(db_path)
644
+ try:
645
+ before = _require_note(db, note_id)
646
+ actions.add_text(before.id, body, mode="replace")
647
+ _verify(
648
+ lambda: (
649
+ (n := db.get_note(before.id)) is not None
650
+ and n.modified is not None
651
+ and (before.modified is None or n.modified > before.modified)
652
+ ),
653
+ f"Replaced body of note {before.id}",
654
+ "note was not modified; is Bear able to run?",
655
+ )
656
+ finally:
657
+ db.close()
658
+
659
+
660
+ @tag_app.command("rename")
661
+ def rename_tag(
662
+ name: Annotated[str, typer.Argument(help="Existing tag name.")],
663
+ new_name: Annotated[str, typer.Argument(help="New tag name.")],
664
+ db_path: DbPathOption = DEFAULT_DB_PATH,
665
+ ) -> None:
666
+ """Rename a tag across all notes."""
667
+ db = _open_db(db_path)
668
+ try:
669
+ existing = {t.lower() for t, _ in db.list_tags()}
670
+ if name.lower() not in existing:
671
+ console.print(f"[red]Error:[/red] no tag named {name!r}")
672
+ raise typer.Exit(1)
673
+ actions.rename_tag(name, new_name)
674
+ _verify(
675
+ lambda: new_name.lower() in {t.lower() for t, _ in db.list_tags()},
676
+ f"Renamed tag {name!r} to {new_name!r}",
677
+ "tag was not renamed; is Bear able to run?",
678
+ )
679
+ finally:
680
+ db.close()
681
+
682
+
683
+ @tag_app.command("delete")
684
+ def delete_tag(
685
+ name: Annotated[str, typer.Argument(help="Tag to delete from all notes.")],
686
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
687
+ db_path: DbPathOption = DEFAULT_DB_PATH,
688
+ ) -> None:
689
+ """Delete a tag from every note that has it."""
690
+ db = _open_db(db_path)
691
+ try:
692
+ counts = {t.lower(): c for t, c in db.list_tags(include_empty=True)}
693
+ if name.lower() not in counts:
694
+ console.print(f"[red]Error:[/red] no tag named {name!r}")
695
+ raise typer.Exit(1)
696
+ if not yes:
697
+ typer.confirm(f"Remove tag '{name}' from {counts[name.lower()]} note(s)?", abort=True)
698
+ actions.delete_tag(name)
699
+ # Bear keeps an empty tag row behind, so verify the count reaches zero.
700
+ _verify(
701
+ lambda: dict((t.lower(), c) for t, c in db.list_tags(include_empty=True)).get(name.lower(), 0) == 0,
702
+ f"Deleted tag {name!r}",
703
+ "tag was not deleted; is Bear able to run?",
704
+ )
705
+ finally:
706
+ db.close()
707
+
708
+
709
+ @note_app.command()
710
+ def search(
711
+ query: Annotated[str, typer.Argument(help="Search terms, matched against titles, tags, and text.")],
712
+ fuzzy: Annotated[bool, typer.Option("--fuzzy", help="Typo-tolerant matching, ranked by score.")] = False,
713
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum number of results.")] = 10,
714
+ min_score: Annotated[float, typer.Option("--min-score", help="Minimum match score, 0-100 (fuzzy only).")] = 60.0,
715
+ tag_filter: Annotated[str | None, typer.Option("--tag", "-t", help="Restrict to notes with this tag.")] = None,
716
+ trashed: Annotated[bool, typer.Option("--trashed", help="Include trashed notes.")] = False,
717
+ archived: Annotated[bool, typer.Option("--archived", help="Include archived notes.")] = False,
718
+ fmt: Annotated[
719
+ OutputFormat,
720
+ typer.Option("--format", "-f", help="Output format: table, json, or text."),
721
+ ] = OutputFormat.table,
722
+ db_path: DbPathOption = DEFAULT_DB_PATH,
723
+ ) -> None:
724
+ """Search notes by title, tags, and content."""
725
+ db = _open_db(db_path)
726
+ try:
727
+ notes = db.list_notes(
728
+ limit=None,
729
+ tag=tag_filter,
730
+ include_trashed=trashed,
731
+ include_archived=archived,
732
+ with_text=True,
733
+ )
734
+ finally:
735
+ db.close()
736
+
737
+ if fuzzy:
738
+ results = search_notes(notes, query, min_score=min_score)[:limit]
739
+ else:
740
+ results = naive_search(notes, query)[:limit]
741
+
742
+ if fmt is OutputFormat.json:
743
+ payload = [
744
+ {**_note_to_dict(r.note), "snippet": r.snippet}
745
+ | ({"score": round(r.score, 1)} if r.score is not None else {})
746
+ for r in results
747
+ ]
748
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
749
+ return
750
+ if fmt is OutputFormat.text:
751
+ for r in results:
752
+ score = f"\t{r.score:.0f}" if r.score is not None else ""
753
+ print(f"{r.note.id}{score}\t{r.note.title}")
754
+ return
755
+
756
+ if not results:
757
+ console.print("No matches.")
758
+ return
759
+
760
+ table = Table(box=box.ROUNDED, header_style="bold")
761
+ if fuzzy:
762
+ table.add_column("Score", justify="right")
763
+ table.add_column("ID", style="dim", no_wrap=True)
764
+ table.add_column("Title", overflow="ellipsis", max_width=40)
765
+ table.add_column("Match", style="green", overflow="ellipsis", max_width=50)
766
+ if not fuzzy:
767
+ table.add_column("Modified", no_wrap=True)
768
+ for r in results:
769
+ row = [r.note.id, r.note.title, r.snippet]
770
+ if fuzzy:
771
+ row.insert(0, f"{r.score:.0f}" if r.score is not None else "")
772
+ else:
773
+ row.append(r.note.modified.strftime("%Y-%m-%d %H:%M") if r.note.modified else "")
774
+ table.add_row(*row)
775
+ console.print(table)
776
+
777
+
778
+ # Top-level aliases for the most-used commands, shown in their own help panel.
779
+ def _alias(name: str, target: str, func: Callable) -> None:
780
+ summary = (func.__doc__ or "").strip().splitlines()[0]
781
+ app.command(name, help=f"{summary} (alias for `bearcli {target}`)", rich_help_panel="Shortcuts")(func)
782
+
783
+
784
+ _alias("list", "note list", list_notes)
785
+ _alias("search", "note search", search)
786
+ _alias("get", "note get", get)
787
+ _alias("open", "note open", open_note)
788
+ _alias("create", "note create", create)