bearcli 1.2.1__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.2.1
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
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Utilities
15
15
  Requires-Dist: detect-secrets>=1.5.0
16
16
  Requires-Dist: rapidfuzz>=3.14.5
17
+ Requires-Dist: textual>=8.2.8
17
18
  Requires-Dist: typer>=0.27.0
18
19
  Requires-Python: >=3.13
19
20
  Project-URL: Documentation, https://github.com/michel-tricot/bearcli/blob/main/docs/IMPLEMENTATION.md
@@ -23,9 +24,9 @@ Description-Content-Type: text/markdown
23
24
 
24
25
  <div align="center">
25
26
 
26
- # 🐻 bearcli
27
+ # 🐻 `bearcli`
27
28
 
28
- **The missing CLI for [Bear](https://bear.app) notes** read, search, export, and
29
+ **The missing CLI for [Bear](https://bear.app) notes** - read, search, export, and
29
30
  manage your notes from the terminal.
30
31
 
31
32
  [![CI](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml/badge.svg)](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml)
@@ -57,6 +58,7 @@ bearcli search "quarterly report" # search titles, tags, and content
57
58
  bearcli get <note-id> # print a note's markdown
58
59
  bearcli create "Idea" --tag inbox # create a note
59
60
  bearcli export ~/bear-backup # export everything as markdown folders
61
+ bearcli ui # full Bear client in the terminal
60
62
  ```
61
63
 
62
64
  ## Commands
@@ -79,8 +81,19 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
79
81
  bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
80
82
  bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
81
83
  bearcli open C44D09DC-... # open in the Bear app
84
+ bearcli ui # Bear in the terminal: search, edit, tag
85
+ bearcli stats # counts, words, top tags, notes per year
82
86
  ```
83
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
+
84
97
  ### Search
85
98
 
86
99
  ```sh
@@ -90,7 +103,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
90
103
 
91
104
  ### Write
92
105
 
93
- Writes go through Bear's x-callback-url API the database itself is never
106
+ Writes go through Bear's x-callback-url API - the database itself is never
94
107
  written. These launch the Bear app if needed.
95
108
 
96
109
  ```sh
@@ -115,19 +128,19 @@ bearcli tag delete old-name # across all notes (asks first)
115
128
 
116
129
  ### Export
117
130
 
118
- Every note becomes a self-contained directory `<slug>/README.md` plus its
119
- attachments with a generated index, so GitHub renders the whole export as a
131
+ Every note becomes a self-contained directory - `<slug>/README.md` plus its
132
+ attachments - with a generated index, so GitHub renders the whole export as a
120
133
  browsable tree.
121
134
 
122
135
  Before anything is written, the notes are scanned for potential secrets
123
136
  (token formats, key blocks, credential assignments, high-entropy strings);
124
137
  findings block the export with a list of the affected notes. Override with
125
138
  `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
126
- with a `[redacted: <rule>]` placeholder notes in Bear are untouched).
139
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
127
140
 
128
- > **⚠️ Warning** detection and redaction are best-effort: a secret that
141
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
129
142
  > reads like ordinary text (a password written as prose, an account number)
130
- > will not be caught. Ideally, don't keep secrets in notes at all use a
143
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
131
144
  > password manager, or at least Bear's encrypted notes, which never leave
132
145
  > the app.
133
146
 
@@ -136,7 +149,7 @@ bearcli export ~/bear-backup
136
149
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
137
150
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
138
151
 
139
- # Mirror to a git repository (clone it first; use a *private* repo these are
152
+ # Mirror to a git repository (clone it first; use a *private* repo - these are
140
153
  # your notes). Bear is the source of truth: remote or manual edits are kept in
141
154
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
142
155
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -158,6 +171,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
158
171
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
159
172
  content cannot be read.
160
173
 
174
+ ## Agent skill
175
+
176
+ An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
177
+ teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
178
+ [`skills/bear-notes`](skills/bear-notes/SKILL.md):
179
+
180
+ ```sh
181
+ cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
182
+ ```
183
+
161
184
  ## Development
162
185
 
163
186
  ```sh
@@ -1,8 +1,8 @@
1
1
  <div align="center">
2
2
 
3
- # 🐻 bearcli
3
+ # 🐻 `bearcli`
4
4
 
5
- **The missing CLI for [Bear](https://bear.app) notes** read, search, export, and
5
+ **The missing CLI for [Bear](https://bear.app) notes** - read, search, export, and
6
6
  manage your notes from the terminal.
7
7
 
8
8
  [![CI](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml/badge.svg)](https://github.com/michel-tricot/bearcli/actions/workflows/ci.yml)
@@ -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,8 +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
60
+ bearcli ui # Bear in the terminal: search, edit, tag
61
+ bearcli stats # counts, words, top tags, notes per year
59
62
  ```
60
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
+
61
73
  ### Search
62
74
 
63
75
  ```sh
@@ -67,7 +79,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
67
79
 
68
80
  ### Write
69
81
 
70
- Writes go through Bear's x-callback-url API the database itself is never
82
+ Writes go through Bear's x-callback-url API - the database itself is never
71
83
  written. These launch the Bear app if needed.
72
84
 
73
85
  ```sh
@@ -92,19 +104,19 @@ bearcli tag delete old-name # across all notes (asks first)
92
104
 
93
105
  ### Export
94
106
 
95
- Every note becomes a self-contained directory `<slug>/README.md` plus its
96
- attachments with a generated index, so GitHub renders the whole export as a
107
+ Every note becomes a self-contained directory - `<slug>/README.md` plus its
108
+ attachments - with a generated index, so GitHub renders the whole export as a
97
109
  browsable tree.
98
110
 
99
111
  Before anything is written, the notes are scanned for potential secrets
100
112
  (token formats, key blocks, credential assignments, high-entropy strings);
101
113
  findings block the export with a list of the affected notes. Override with
102
114
  `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
103
- with a `[redacted: <rule>]` placeholder notes in Bear are untouched).
115
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
104
116
 
105
- > **⚠️ Warning** detection and redaction are best-effort: a secret that
117
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
106
118
  > reads like ordinary text (a password written as prose, an account number)
107
- > will not be caught. Ideally, don't keep secrets in notes at all use a
119
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
108
120
  > password manager, or at least Bear's encrypted notes, which never leave
109
121
  > the app.
110
122
 
@@ -113,7 +125,7 @@ bearcli export ~/bear-backup
113
125
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
114
126
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
115
127
 
116
- # Mirror to a git repository (clone it first; use a *private* repo these are
128
+ # Mirror to a git repository (clone it first; use a *private* repo - these are
117
129
  # your notes). Bear is the source of truth: remote or manual edits are kept in
118
130
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
119
131
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -135,6 +147,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
135
147
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
136
148
  content cannot be read.
137
149
 
150
+ ## Agent skill
151
+
152
+ An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
153
+ teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
154
+ [`skills/bear-notes`](skills/bear-notes/SKILL.md):
155
+
156
+ ```sh
157
+ cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
158
+ ```
159
+
138
160
  ## Development
139
161
 
140
162
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.2.1"
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"]
@@ -20,6 +20,7 @@ requires-python = ">=3.13"
20
20
  dependencies = [
21
21
  "detect-secrets>=1.5.0",
22
22
  "rapidfuzz>=3.14.5",
23
+ "textual>=8.2.8",
23
24
  "typer>=0.27.0",
24
25
  ]
25
26
 
@@ -37,6 +38,7 @@ build-backend = "uv_build"
37
38
 
38
39
  [dependency-groups]
39
40
  dev = [
41
+ "pytest>=9.1.1",
40
42
  "ruff>=0.16.0",
41
43
  "ty>=0.0.64",
42
44
  ]
@@ -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)