bearcli 1.2.1__tar.gz → 1.3.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.3.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)
@@ -79,6 +80,8 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
79
80
  bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
80
81
  bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
81
82
  bearcli open C44D09DC-... # open in the Bear app
83
+ bearcli browse # interactive: type to filter, Enter opens
84
+ bearcli stats # counts, words, top tags, notes per year
82
85
  ```
83
86
 
84
87
  ### Search
@@ -90,7 +93,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
90
93
 
91
94
  ### Write
92
95
 
93
- Writes go through Bear's x-callback-url API the database itself is never
96
+ Writes go through Bear's x-callback-url API - the database itself is never
94
97
  written. These launch the Bear app if needed.
95
98
 
96
99
  ```sh
@@ -115,19 +118,19 @@ bearcli tag delete old-name # across all notes (asks first)
115
118
 
116
119
  ### Export
117
120
 
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
121
+ Every note becomes a self-contained directory - `<slug>/README.md` plus its
122
+ attachments - with a generated index, so GitHub renders the whole export as a
120
123
  browsable tree.
121
124
 
122
125
  Before anything is written, the notes are scanned for potential secrets
123
126
  (token formats, key blocks, credential assignments, high-entropy strings);
124
127
  findings block the export with a list of the affected notes. Override with
125
128
  `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
126
- with a `[redacted: <rule>]` placeholder notes in Bear are untouched).
129
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
127
130
 
128
- > **⚠️ Warning** detection and redaction are best-effort: a secret that
131
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
129
132
  > 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
133
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
131
134
  > password manager, or at least Bear's encrypted notes, which never leave
132
135
  > the app.
133
136
 
@@ -136,7 +139,7 @@ bearcli export ~/bear-backup
136
139
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
137
140
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
138
141
 
139
- # Mirror to a git repository (clone it first; use a *private* repo these are
142
+ # Mirror to a git repository (clone it first; use a *private* repo - these are
140
143
  # your notes). Bear is the source of truth: remote or manual edits are kept in
141
144
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
142
145
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -158,6 +161,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
158
161
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
159
162
  content cannot be read.
160
163
 
164
+ ## Agent skill
165
+
166
+ An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
167
+ teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
168
+ [`skills/bear-notes`](skills/bear-notes/SKILL.md):
169
+
170
+ ```sh
171
+ cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
172
+ ```
173
+
161
174
  ## Development
162
175
 
163
176
  ```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)
@@ -56,6 +56,8 @@ bearcli get C44D09DC-... --meta # with YAML-style frontmatter
56
56
  bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
57
57
  bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
58
58
  bearcli open C44D09DC-... # open in the Bear app
59
+ bearcli browse # interactive: type to filter, Enter opens
60
+ bearcli stats # counts, words, top tags, notes per year
59
61
  ```
60
62
 
61
63
  ### Search
@@ -67,7 +69,7 @@ bearcli search "quarterly planing" --fuzzy # typo-tolerant, ranked by score
67
69
 
68
70
  ### Write
69
71
 
70
- Writes go through Bear's x-callback-url API the database itself is never
72
+ Writes go through Bear's x-callback-url API - the database itself is never
71
73
  written. These launch the Bear app if needed.
72
74
 
73
75
  ```sh
@@ -92,19 +94,19 @@ bearcli tag delete old-name # across all notes (asks first)
92
94
 
93
95
  ### Export
94
96
 
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
97
+ Every note becomes a self-contained directory - `<slug>/README.md` plus its
98
+ attachments - with a generated index, so GitHub renders the whole export as a
97
99
  browsable tree.
98
100
 
99
101
  Before anything is written, the notes are scanned for potential secrets
100
102
  (token formats, key blocks, credential assignments, high-entropy strings);
101
103
  findings block the export with a list of the affected notes. Override with
102
104
  `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
103
- with a `[redacted: <rule>]` placeholder notes in Bear are untouched).
105
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
104
106
 
105
- > **⚠️ Warning** detection and redaction are best-effort: a secret that
107
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
106
108
  > 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
109
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
108
110
  > password manager, or at least Bear's encrypted notes, which never leave
109
111
  > the app.
110
112
 
@@ -113,7 +115,7 @@ bearcli export ~/bear-backup
113
115
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
114
116
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
115
117
 
116
- # Mirror to a git repository (clone it first; use a *private* repo these are
118
+ # Mirror to a git repository (clone it first; use a *private* repo - these are
117
119
  # your notes). Bear is the source of truth: remote or manual edits are kept in
118
120
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
119
121
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -135,6 +137,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
135
137
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
136
138
  content cannot be read.
137
139
 
140
+ ## Agent skill
141
+
142
+ An [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
143
+ teaching AI agents (Claude Code, etc.) how to use `bearcli` ships in
144
+ [`skills/bear-notes`](skills/bear-notes/SKILL.md):
145
+
146
+ ```sh
147
+ cp -r skills/bear-notes ~/.claude/skills/ # or a project's .claude/skills/
148
+ ```
149
+
138
150
  ## Development
139
151
 
140
152
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.2.1"
3
+ version = "1.3.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
  ]
@@ -841,3 +841,93 @@ _alias("search", "note search", search)
841
841
  _alias("get", "note get", get)
842
842
  _alias("open", "note open", open_note)
843
843
  _alias("create", "note create", create)
844
+
845
+
846
+ @app.command()
847
+ def stats(
848
+ fmt: Annotated[
849
+ OutputFormat,
850
+ typer.Option("--format", "-f", help="Output format: table or json."),
851
+ ] = OutputFormat.table,
852
+ db_path: DbPathOption = DEFAULT_DB_PATH,
853
+ ) -> None:
854
+ """Show statistics about the note library."""
855
+ db = _open_db(db_path)
856
+ try:
857
+ notes = db.list_notes(limit=None, include_trashed=True, include_archived=True, with_text=True)
858
+ tag_counts = db.list_tags()
859
+ attachment_count, attachment_bytes = db.attachment_stats()
860
+ finally:
861
+ db.close()
862
+
863
+ active = [n for n in notes if not n.trashed and not n.archived]
864
+ by_year: dict[str, int] = {}
865
+ for n in notes:
866
+ if not n.trashed and n.created:
867
+ year = str(n.created.year)
868
+ by_year[year] = by_year.get(year, 0) + 1
869
+
870
+ data = {
871
+ "notes": len(notes),
872
+ "active": len(active),
873
+ "archived": sum(1 for n in notes if n.archived and not n.trashed),
874
+ "trashed": sum(1 for n in notes if n.trashed),
875
+ "pinned": sum(1 for n in notes if n.pinned and not n.trashed),
876
+ "encrypted": sum(1 for n in notes if n.encrypted and not n.trashed),
877
+ "words": sum(len((n.text or "").split()) for n in notes if not n.trashed),
878
+ "tags": len(tag_counts),
879
+ "attachments": attachment_count,
880
+ "attachment_bytes": attachment_bytes,
881
+ "notes_by_year": dict(sorted(by_year.items())),
882
+ "top_tags": [{"tag": t, "notes": c} for t, c in sorted(tag_counts, key=lambda tc: -tc[1])[:10]],
883
+ }
884
+
885
+ if fmt is OutputFormat.json:
886
+ print(json.dumps(data, indent=2, ensure_ascii=False))
887
+ return
888
+
889
+ table = Table(box=box.ROUNDED, show_header=False)
890
+ table.add_column(style="bold")
891
+ table.add_column(justify="right")
892
+ table.add_row("Notes", str(data["notes"]))
893
+ table.add_row(" active", str(data["active"]))
894
+ table.add_row(" archived", str(data["archived"]))
895
+ table.add_row(" trashed", str(data["trashed"]))
896
+ table.add_row(" pinned", str(data["pinned"]))
897
+ table.add_row(" encrypted", str(data["encrypted"]))
898
+ table.add_row("Words", f"{data['words']:,}")
899
+ table.add_row("Tags", str(data["tags"]))
900
+ table.add_row("Attachments", f"{attachment_count} ({attachment_bytes / 1_000_000:.1f} MB)")
901
+ console.print(table)
902
+
903
+ if data["top_tags"]:
904
+ tag_table = Table(box=box.ROUNDED, header_style="bold")
905
+ tag_table.add_column("Top tags", style="cyan")
906
+ tag_table.add_column("Notes", justify="right")
907
+ for entry in data["top_tags"]:
908
+ tag_table.add_row(str(entry["tag"]), str(entry["notes"]))
909
+ console.print(tag_table)
910
+
911
+ year_table = Table(box=box.ROUNDED, header_style="bold")
912
+ year_table.add_column("Created", style="cyan")
913
+ year_table.add_column("Notes", justify="right")
914
+ for year, count in data["notes_by_year"].items():
915
+ year_table.add_row(year, str(count))
916
+ console.print(year_table)
917
+
918
+
919
+ @app.command()
920
+ def browse(
921
+ fuzzy: Annotated[bool, typer.Option("--fuzzy", help="Typo-tolerant ranked filtering.")] = False,
922
+ tag_filter: Annotated[str | None, typer.Option("--tag", "-t", help="Restrict to notes with this tag.")] = None,
923
+ db_path: DbPathOption = DEFAULT_DB_PATH,
924
+ ) -> None:
925
+ """Browse notes interactively: type to filter, Enter opens the note in Bear."""
926
+ from bearcli.tui import browse as run_browser
927
+
928
+ db = _open_db(db_path)
929
+ try:
930
+ notes = db.list_notes(limit=None, tag=tag_filter, with_text=True)
931
+ finally:
932
+ db.close()
933
+ run_browser(notes, fuzzy=fuzzy)
@@ -144,6 +144,18 @@ class BearDB:
144
144
  ).fetchall()
145
145
  return [(row[0], row[1]) for row in rows]
146
146
 
147
+ def attachment_stats(self) -> tuple[int, int]:
148
+ """(count, total bytes) of attachments on non-deleted notes."""
149
+ row = self.conn.execute(
150
+ """
151
+ SELECT COUNT(*), COALESCE(SUM(f.ZFILESIZE), 0)
152
+ FROM ZSFNOTEFILE f
153
+ JOIN ZSFNOTE n ON n.Z_PK = f.ZNOTE
154
+ WHERE f.ZPERMANENTLYDELETED = 0 AND n.ZPERMANENTLYDELETED = 0 AND n.ZTRASHED = 0
155
+ """
156
+ ).fetchone()
157
+ return row[0], row[1]
158
+
147
159
  def _attachments_for_note(self, note_pk: int) -> list[Attachment]:
148
160
  rows = self.conn.execute(
149
161
  """
@@ -0,0 +1,83 @@
1
+ """Interactive note browser (Textual)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Horizontal
7
+ from textual.widgets import Input, OptionList, Static
8
+ from textual.widgets.option_list import Option
9
+
10
+ from bearcli import actions
11
+ from bearcli.db import Note
12
+ from bearcli.search import naive_search, search_notes
13
+
14
+ MAX_RESULTS = 50
15
+
16
+
17
+ class BrowseApp(App):
18
+ """Type to filter, arrows to move, Enter to open in Bear, Esc to quit."""
19
+
20
+ CSS = """
21
+ #query { dock: top; }
22
+ #status { dock: bottom; height: 1; color: $text-muted; padding: 0 1; }
23
+ #results { height: 1fr; }
24
+ #preview { width: 45%; border-left: solid $surface; padding: 0 1; color: $text-muted; }
25
+ """
26
+ BINDINGS = [("escape", "quit", "Quit")]
27
+
28
+ def __init__(self, notes: list[Note], fuzzy: bool = False):
29
+ super().__init__()
30
+ self.notes = notes
31
+ self.fuzzy = fuzzy
32
+ self.shown: list[Note] = []
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield Input(placeholder="Search notes…", id="query")
36
+ with Horizontal():
37
+ yield OptionList(id="results")
38
+ yield Static(id="preview")
39
+ yield Static(id="status")
40
+
41
+ def on_mount(self) -> None:
42
+ self._refresh("")
43
+
44
+ def on_input_changed(self, event: Input.Changed) -> None:
45
+ self._refresh(event.value)
46
+
47
+ def _refresh(self, query: str) -> None:
48
+ if not query.strip():
49
+ self.shown = self.notes[:MAX_RESULTS]
50
+ elif self.fuzzy:
51
+ self.shown = [r.note for r in search_notes(self.notes, query)[:MAX_RESULTS]]
52
+ else:
53
+ self.shown = [r.note for r in naive_search(self.notes, query)[:MAX_RESULTS]]
54
+
55
+ results = self.query_one("#results", OptionList)
56
+ results.clear_options()
57
+ for note in self.shown:
58
+ date = note.modified.strftime("%Y-%m-%d") if note.modified else " "
59
+ tags = f" #{' #'.join(note.tags)}" if note.tags else ""
60
+ results.add_option(Option(f"{date} {note.title}{tags}", id=note.id))
61
+ if self.shown:
62
+ results.highlighted = 0
63
+ self.query_one("#status", Static).update(
64
+ f"{len(self.shown)} shown / {len(self.notes)} notes · Enter: open in Bear · Esc: quit"
65
+ )
66
+
67
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
68
+ note = next((n for n in self.shown if n.id == event.option_id), None)
69
+ preview = "\n".join((note.text or "").splitlines()[:40]) if note else ""
70
+ self.query_one("#preview", Static).update(preview)
71
+
72
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
73
+ if event.option_id:
74
+ actions.open_note(event.option_id)
75
+
76
+ def on_input_submitted(self, event: Input.Submitted) -> None:
77
+ results = self.query_one("#results", OptionList)
78
+ if self.shown and results.highlighted is not None:
79
+ actions.open_note(self.shown[results.highlighted].id)
80
+
81
+
82
+ def browse(notes: list[Note], fuzzy: bool = False) -> None:
83
+ BrowseApp(notes, fuzzy=fuzzy).run()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes