bearcli 1.3.0__tar.gz → 1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bearcli
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal
5
5
  Keywords: bear,notes,cli,markdown,macos
6
6
  Author: Michel Tricot
@@ -58,6 +58,7 @@ bearcli search "quarterly report" # search titles, tags, and content
58
58
  bearcli get <note-id> # print a note's markdown
59
59
  bearcli create "Idea" --tag inbox # create a note
60
60
  bearcli export ~/bear-backup # export everything as markdown folders
61
+ bearcli ui # full Bear client in the terminal
61
62
  ```
62
63
 
63
64
  ## Commands
@@ -80,10 +81,19 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
80
81
  bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
81
82
  bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
82
83
  bearcli open C44D09DC-... # open in the Bear app
83
- bearcli browse # interactive: type to filter, Enter opens
84
+ bearcli ui # Bear in the terminal: search, edit, tag
84
85
  bearcli stats # counts, words, top tags, notes per year
85
86
  ```
86
87
 
88
+ ### Terminal UI
89
+
90
+ `bearcli ui` is a full Bear client in the terminal: the note list with live
91
+ filtering on the left, preview and inline markdown editor on the right.
92
+ `/` search - `Enter` edit - `n`/`c` new note - `t`/`T` tag/untag (with
93
+ autocompletion) - `o` open in Bear - `a` archive - `d` trash - `r` reload.
94
+ Notes with detected secrets are marked 🚨; encrypted notes show 🔒 and keep
95
+ their content in Bear.
96
+
87
97
  ### Search
88
98
 
89
99
  ```sh
@@ -34,6 +34,7 @@ bearcli search "quarterly report" # search titles, tags, and content
34
34
  bearcli get <note-id> # print a note's markdown
35
35
  bearcli create "Idea" --tag inbox # create a note
36
36
  bearcli export ~/bear-backup # export everything as markdown folders
37
+ bearcli ui # full Bear client in the terminal
37
38
  ```
38
39
 
39
40
  ## Commands
@@ -56,10 +57,19 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
56
57
  bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
57
58
  bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
58
59
  bearcli open C44D09DC-... # open in the Bear app
59
- bearcli browse # interactive: type to filter, Enter opens
60
+ bearcli ui # Bear in the terminal: search, edit, tag
60
61
  bearcli stats # counts, words, top tags, notes per year
61
62
  ```
62
63
 
64
+ ### Terminal UI
65
+
66
+ `bearcli ui` is a full Bear client in the terminal: the note list with live
67
+ filtering on the left, preview and inline markdown editor on the right.
68
+ `/` search - `Enter` edit - `n`/`c` new note - `t`/`T` tag/untag (with
69
+ autocompletion) - `o` open in Bear - `a` archive - `d` trash - `r` reload.
70
+ Notes with detected secrets are marked 🚨; encrypted notes show 🔒 and keep
71
+ their content in Bear.
72
+
63
73
  ### Search
64
74
 
65
75
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  description = "The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal"
5
5
  readme = "README.md"
6
6
  keywords = ["bear", "notes", "cli", "markdown", "macos"]
@@ -0,0 +1,25 @@
1
+ """Typer CLI for reading notes from the Bear note app."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from bearcli.cli import export, misc, notes, tags # noqa: F401 # command registration
6
+ from bearcli.cli.common import app
7
+ from bearcli.cli.notes import create, get, list_notes, open_note, search
8
+
9
+
10
+ def _alias(name: str, target: str, func: Callable) -> None:
11
+ summary = (func.__doc__ or "").strip().splitlines()[0]
12
+ app.command(name, help=f"{summary} (alias for `bearcli {target}`)", rich_help_panel="Shortcuts")(func)
13
+
14
+
15
+ _alias("list", "note list", list_notes)
16
+
17
+ _alias("search", "note search", search)
18
+
19
+ _alias("get", "note get", get)
20
+
21
+ _alias("open", "note open", open_note)
22
+
23
+ _alias("create", "note create", create)
24
+
25
+ __all__ = ["app"]
@@ -0,0 +1,112 @@
1
+ """Shared Typer apps, output types, and helpers for the CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from bearcli import actions
16
+ from bearcli.db import AmbiguousNoteId, BearDB, Note, note_metadata
17
+
18
+ app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
19
+
20
+ note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
21
+
22
+ tag_app = typer.Typer(help="List and manage tags.", no_args_is_help=True)
23
+
24
+ app.add_typer(note_app, name="note")
25
+
26
+ app.add_typer(tag_app, name="tag")
27
+
28
+ console = Console()
29
+
30
+
31
+ class OutputFormat(StrEnum):
32
+ table = "table"
33
+ json = "json"
34
+ text = "text"
35
+
36
+
37
+ class OnlyFilter(StrEnum):
38
+ pinned = "pinned"
39
+ encrypted = "encrypted"
40
+ trashed = "trashed"
41
+ archived = "archived"
42
+
43
+
44
+ def _note_to_dict(note: Note, with_text: bool = False) -> dict:
45
+ data = note_metadata(note)
46
+ if with_text:
47
+ data["text"] = note.text
48
+ data["attachments"] = [
49
+ {
50
+ "filename": a.filename,
51
+ "path": str(a.path),
52
+ "size": a.size,
53
+ "exists": a.exists,
54
+ }
55
+ for a in note.attachments
56
+ ]
57
+ return data
58
+
59
+
60
+ DbPathOption = Annotated[
61
+ Path,
62
+ typer.Option("--db", envvar="BEAR_DB_PATH", help="Path to the Bear SQLite database."),
63
+ ]
64
+
65
+
66
+ def _open_db(path: Path) -> BearDB:
67
+ try:
68
+ return BearDB(path)
69
+ except FileNotFoundError as exc:
70
+ console.print(f"[red]Error:[/red] {exc}")
71
+ raise typer.Exit(1) from None
72
+
73
+
74
+ def _parse_date(value: str | None, option: str) -> datetime | None:
75
+ if value is None:
76
+ return None
77
+ try:
78
+ return datetime.fromisoformat(value)
79
+ except ValueError:
80
+ console.print(
81
+ f"[red]Error:[/red] invalid date for {option}: {value!r} "
82
+ "(expected ISO format, e.g. 2026-07-01 or 2026-07-01T14:30)"
83
+ )
84
+ raise typer.Exit(2) from None
85
+
86
+
87
+ def _text_or_stdin(text: str | None) -> str | None:
88
+ if text is None and not sys.stdin.isatty():
89
+ return sys.stdin.read()
90
+ return text
91
+
92
+
93
+ def _require_note(db: BearDB, note_id: str) -> Note:
94
+ try:
95
+ note = db.get_note(note_id)
96
+ except AmbiguousNoteId as exc:
97
+ console.print(f"[red]Error:[/red] note id prefix {exc.prefix!r} matches several notes:")
98
+ for full_id, title in exc.matches:
99
+ console.print(f" {full_id} {title}")
100
+ raise typer.Exit(1) from None
101
+ if note is None:
102
+ console.print(f"[red]Error:[/red] no note with id {note_id!r}")
103
+ raise typer.Exit(1)
104
+ return note
105
+
106
+
107
+ def _verify(ok: Callable[[], bool], success: str, failure: str) -> None:
108
+ if actions.wait_for(ok):
109
+ console.print(success)
110
+ else:
111
+ console.print(f"[red]Error:[/red] {failure}")
112
+ raise typer.Exit(1)
@@ -0,0 +1,118 @@
1
+ """The export command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich import box
10
+ from rich.markup import escape as rich_escape
11
+ from rich.table import Table
12
+
13
+ from bearcli.cli.common import (
14
+ DbPathOption,
15
+ _open_db,
16
+ app,
17
+ console,
18
+ )
19
+ from bearcli.db import DEFAULT_DB_PATH
20
+ from bearcli.export import export_notes
21
+ from bearcli.gitsync import GitError, export_and_push
22
+ from bearcli.secrets import SecretFinding, redaction_map, scan_notes
23
+
24
+
25
+ def _report_secrets(findings: list[SecretFinding]) -> None:
26
+ table = Table(box=box.ROUNDED, header_style="bold")
27
+ table.add_column("Note", overflow="ellipsis", max_width=30)
28
+ table.add_column("ID", style="dim", no_wrap=True)
29
+ table.add_column("Rule", style="yellow")
30
+ table.add_column("Line", justify="right")
31
+ table.add_column("Match", style="red")
32
+ for f in findings:
33
+ table.add_row(f.note_title, f.note_id, f.rule, str(f.line), f.excerpt)
34
+ console.print(table)
35
+ notes = len({f.note_id for f in findings})
36
+ console.print(
37
+ f"[red]Export blocked:[/red] {len(findings)} potential secret(s) in {notes} note(s). "
38
+ "Move them somewhere safe (or into an encrypted note), re-run with --redact-secrets "
39
+ "to export with placeholders, or with --allow-secrets to export as-is."
40
+ )
41
+
42
+
43
+ @app.command()
44
+ def export(
45
+ dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
46
+ sync: Annotated[
47
+ bool,
48
+ typer.Option(
49
+ "--sync",
50
+ help="Only rewrite notes that changed since the last export instead of everything.",
51
+ ),
52
+ ] = False,
53
+ push: Annotated[
54
+ bool,
55
+ typer.Option(
56
+ "--push",
57
+ help="Treat DEST as a git clone: commit the export and push. Bear is the source of "
58
+ "truth — remote or manual edits are kept in history but overwritten in HEAD.",
59
+ ),
60
+ ] = False,
61
+ allow_secrets: Annotated[
62
+ bool,
63
+ typer.Option("--allow-secrets", help="Export even if the secret scan finds potential credentials."),
64
+ ] = False,
65
+ redact_secrets: Annotated[
66
+ bool,
67
+ typer.Option(
68
+ "--redact-secrets",
69
+ help="Export with detected secrets replaced by a [redacted: <rule>] placeholder "
70
+ "(notes in Bear are untouched).",
71
+ ),
72
+ ] = False,
73
+ db_path: DbPathOption = DEFAULT_DB_PATH,
74
+ ) -> None:
75
+ """Export all notes as markdown files with frontmatter and attachments."""
76
+ if allow_secrets and redact_secrets:
77
+ console.print("[red]Error:[/red] --allow-secrets and --redact-secrets are mutually exclusive")
78
+ raise typer.Exit(2)
79
+ db = _open_db(db_path)
80
+ try:
81
+ redactions: dict[str, dict[str, str]] | None = None
82
+ if not allow_secrets:
83
+ with console.status("Scanning notes for secrets…", spinner="dots"):
84
+ candidates = db.list_notes(limit=None, include_archived=True, with_text=True)
85
+ findings = scan_notes(candidates)
86
+ if findings and not redact_secrets:
87
+ _report_secrets(findings)
88
+ raise typer.Exit(1)
89
+ if findings:
90
+ redactions = redaction_map(findings)
91
+ with console.status("Exporting…", spinner="dots") as status:
92
+ update = lambda msg: status.update(rich_escape(msg)) # noqa: E731
93
+ if push:
94
+ try:
95
+ result, outcome = export_and_push(db, dest, sync=sync, progress=update, redactions=redactions)
96
+ except GitError as exc:
97
+ console.print(f"[red]Error:[/red] {exc}")
98
+ raise typer.Exit(1) from None
99
+ else:
100
+ result = export_notes(db, dest, sync=sync, progress=update, redactions=redactions)
101
+ finally:
102
+ db.close()
103
+
104
+ parts = [f"{result.written} written"]
105
+ if push:
106
+ parts.append(outcome)
107
+ if sync:
108
+ parts.append(f"{result.unchanged} unchanged")
109
+ if result.removed:
110
+ parts.append(f"{result.removed} removed")
111
+ if result.skipped_encrypted:
112
+ parts.append(f"{result.skipped_encrypted} encrypted skipped")
113
+ if result.index_updated:
114
+ parts.append("index updated")
115
+ if redactions:
116
+ secrets_count = sum(len(v) for v in redactions.values())
117
+ parts.append(f"{secrets_count} secret(s) redacted in {len(redactions)} note(s)")
118
+ console.print(f"Exported to {dest}: " + ", ".join(parts))
@@ -0,0 +1,109 @@
1
+ """Library-wide commands: stats and the terminal UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich import box
10
+ from rich.table import Table
11
+
12
+ from bearcli.cli.common import (
13
+ DbPathOption,
14
+ OutputFormat,
15
+ _open_db,
16
+ app,
17
+ console,
18
+ )
19
+ from bearcli.db import DEFAULT_DB_PATH
20
+
21
+
22
+ @app.command()
23
+ def stats(
24
+ fmt: Annotated[
25
+ OutputFormat,
26
+ typer.Option("--format", "-f", help="Output format: table or json."),
27
+ ] = OutputFormat.table,
28
+ db_path: DbPathOption = DEFAULT_DB_PATH,
29
+ ) -> None:
30
+ """Show statistics about the note library."""
31
+ db = _open_db(db_path)
32
+ try:
33
+ notes = db.list_notes(limit=None, include_trashed=True, include_archived=True, with_text=True)
34
+ tag_counts = db.list_tags()
35
+ attachment_count, attachment_bytes = db.attachment_stats()
36
+ finally:
37
+ db.close()
38
+
39
+ active = [n for n in notes if not n.trashed and not n.archived]
40
+ by_year: dict[str, int] = {}
41
+ for n in notes:
42
+ if not n.trashed and n.created:
43
+ year = str(n.created.year)
44
+ by_year[year] = by_year.get(year, 0) + 1
45
+
46
+ data = {
47
+ "notes": len(notes),
48
+ "active": len(active),
49
+ "archived": sum(1 for n in notes if n.archived and not n.trashed),
50
+ "trashed": sum(1 for n in notes if n.trashed),
51
+ "pinned": sum(1 for n in notes if n.pinned and not n.trashed),
52
+ "encrypted": sum(1 for n in notes if n.encrypted and not n.trashed),
53
+ "words": sum(len((n.text or "").split()) for n in notes if not n.trashed),
54
+ "tags": len(tag_counts),
55
+ "attachments": attachment_count,
56
+ "attachment_bytes": attachment_bytes,
57
+ "notes_by_year": dict(sorted(by_year.items())),
58
+ "top_tags": [{"tag": t, "notes": c} for t, c in sorted(tag_counts, key=lambda tc: -tc[1])[:10]],
59
+ }
60
+
61
+ if fmt is OutputFormat.json:
62
+ print(json.dumps(data, indent=2, ensure_ascii=False))
63
+ return
64
+
65
+ table = Table(box=box.ROUNDED, show_header=False, width=44)
66
+ table.add_column(style="bold")
67
+ table.add_column(justify="right")
68
+ table.add_row("Notes", str(data["notes"]))
69
+ table.add_row(" active", str(data["active"]))
70
+ table.add_row(" archived", str(data["archived"]))
71
+ table.add_row(" trashed", str(data["trashed"]))
72
+ table.add_row(" pinned", str(data["pinned"]))
73
+ table.add_row(" encrypted", str(data["encrypted"]))
74
+ table.add_row("Words", f"{data['words']:,}")
75
+ table.add_row("Tags", str(data["tags"]))
76
+ table.add_row("Attachments", f"{attachment_count} ({attachment_bytes / 1_000_000:.1f} MB)")
77
+ console.print(table)
78
+
79
+ if data["top_tags"]:
80
+ tag_table = Table(box=box.ROUNDED, header_style="bold", width=44)
81
+ tag_table.add_column("Top tags", style="cyan")
82
+ tag_table.add_column("Notes", justify="right")
83
+ for entry in data["top_tags"]:
84
+ tag_table.add_row(str(entry["tag"]), str(entry["notes"]))
85
+ console.print(tag_table)
86
+
87
+ year_table = Table(box=box.ROUNDED, header_style="bold", width=44)
88
+ year_table.add_column("Created", style="cyan")
89
+ year_table.add_column("Notes", justify="right")
90
+ for year, count in data["notes_by_year"].items():
91
+ year_table.add_row(year, str(count))
92
+ console.print(year_table)
93
+
94
+
95
+ @app.command()
96
+ def ui(
97
+ fuzzy: Annotated[bool, typer.Option("--fuzzy", help="Typo-tolerant ranked filtering.")] = False,
98
+ tag_filter: Annotated[str | None, typer.Option("--tag", "-t", help="Restrict to notes with this tag.")] = None,
99
+ db_path: DbPathOption = DEFAULT_DB_PATH,
100
+ ) -> None:
101
+ """Bear in the terminal: search, edit, create, tag, and organize notes."""
102
+ from bearcli.tui import run_ui
103
+
104
+ db = _open_db(db_path)
105
+ try:
106
+ notes = db.list_notes(limit=None, tag=tag_filter, with_text=True)
107
+ finally:
108
+ db.close()
109
+ run_ui(notes, fuzzy=fuzzy, db_path=db_path, tag_filter=tag_filter)