bearcli 1.2.0__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.0
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,20 +118,28 @@ 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
- (token formats, key blocks, credential assignments); findings block the export
124
- with a list of the affected notes (`--allow-secrets` overrides).
126
+ (token formats, key blocks, credential assignments, high-entropy strings);
127
+ findings block the export with a list of the affected notes. Override with
128
+ `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
129
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
130
+
131
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
132
+ > reads like ordinary text (a password written as prose, an account number)
133
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
134
+ > password manager, or at least Bear's encrypted notes, which never leave
135
+ > the app.
125
136
 
126
137
  ```sh
127
138
  bearcli export ~/bear-backup
128
139
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
129
140
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
130
141
 
131
- # 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
132
143
  # your notes). Bear is the source of truth: remote or manual edits are kept in
133
144
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
134
145
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -150,6 +161,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
150
161
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
151
162
  content cannot be read.
152
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
+
153
174
  ## Development
154
175
 
155
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,20 +94,28 @@ 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
- (token formats, key blocks, credential assignments); findings block the export
101
- with a list of the affected notes (`--allow-secrets` overrides).
102
+ (token formats, key blocks, credential assignments, high-entropy strings);
103
+ findings block the export with a list of the affected notes. Override with
104
+ `--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
105
+ with a `[redacted: <rule>]` placeholder - notes in Bear are untouched).
106
+
107
+ > **⚠️ Warning** - detection and redaction are best-effort: a secret that
108
+ > reads like ordinary text (a password written as prose, an account number)
109
+ > will not be caught. Ideally, don't keep secrets in notes at all - use a
110
+ > password manager, or at least Bear's encrypted notes, which never leave
111
+ > the app.
102
112
 
103
113
  ```sh
104
114
  bearcli export ~/bear-backup
105
115
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
106
116
  bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
107
117
 
108
- # 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
109
119
  # your notes). Bear is the source of truth: remote or manual edits are kept in
110
120
  # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
111
121
  git clone git@github.com:you/bear-notes.git ~/bear-notes
@@ -127,6 +137,16 @@ defaults to Bear's standard location and can be overridden with `--db` or the
127
137
  `BEAR_DB_PATH` environment variable. Encrypted notes are listed but their
128
138
  content cannot be read.
129
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
+
130
150
  ## Development
131
151
 
132
152
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.2.0"
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