msg-summarizer 0.1.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.
File without changes
@@ -0,0 +1,125 @@
1
+ from typing import Iterator
2
+
3
+ import anthropic
4
+
5
+ from .models import Conversation
6
+ from .summarizer import (
7
+ _DEFAULT_TOKENS_PER_MINUTE,
8
+ _RESPONSE_TOKENS,
9
+ _chunk_budget,
10
+ fit_recent,
11
+ format_transcript,
12
+ make_client,
13
+ _MODEL,
14
+ )
15
+
16
+ _SYSTEM_TEMPLATE = """\
17
+ You are a thoughtful, candid assistant helping the user navigate their iMessage conversations.
18
+
19
+ You have full access to the message history between the user and {contact} \
20
+ ({count} messages, {start} to {end}). Use it to answer questions, offer honest \
21
+ advice on how to respond, interpret tone and intent, and draft replies when asked.
22
+
23
+ Be concise and direct. When suggesting a response, offer specific wording. \
24
+ When you notice patterns in the other person's communication style, point them out.
25
+
26
+ --- CONVERSATION ({count} messages, {start} to {end}) ---
27
+ {transcript}
28
+ --- END OF CONVERSATION ---\
29
+ """
30
+
31
+
32
+ def prepare_chat(
33
+ conversation: Conversation,
34
+ tokens_per_minute: int = _DEFAULT_TOKENS_PER_MINUTE,
35
+ ) -> tuple[str, int, int, str, str]:
36
+ """Trim *conversation* to fit the rate limit and build the chat system prompt.
37
+
38
+ Returns (system_prompt, used_count, dropped_count, start, end). An interactive
39
+ session can't be chunked, so the most recent messages that fit are kept.
40
+ """
41
+ kept, dropped = fit_recent(conversation.messages, _chunk_budget(tokens_per_minute))
42
+ convo = Conversation(conversation.contact, kept)
43
+ start = convo.messages[0].timestamp.strftime("%Y-%m-%d")
44
+ end = convo.messages[-1].timestamp.strftime("%Y-%m-%d")
45
+ system_prompt = _SYSTEM_TEMPLATE.format(
46
+ contact=convo.contact,
47
+ count=len(kept),
48
+ start=start,
49
+ end=end,
50
+ transcript=format_transcript(convo),
51
+ )
52
+ return system_prompt, len(kept), dropped, start, end
53
+
54
+
55
+ def stream_chat(
56
+ client: anthropic.Anthropic,
57
+ system_prompt: str,
58
+ history: list[dict],
59
+ ) -> Iterator[str]:
60
+ """Stream a reply for *history* given the cached *system_prompt*, yielding text."""
61
+ with client.messages.stream(
62
+ model=_MODEL,
63
+ max_tokens=_RESPONSE_TOKENS,
64
+ system=[
65
+ {
66
+ "type": "text",
67
+ "text": system_prompt,
68
+ # Cache the transcript — stable for the whole session
69
+ "cache_control": {"type": "ephemeral"},
70
+ }
71
+ ],
72
+ messages=history,
73
+ ) as stream:
74
+ yield from stream.text_stream
75
+
76
+
77
+ def run_chat(
78
+ conversation: Conversation,
79
+ tokens_per_minute: int = _DEFAULT_TOKENS_PER_MINUTE,
80
+ ) -> None:
81
+ """Start an interactive Q&A session about *conversation*. Streams responses."""
82
+ if not conversation.messages:
83
+ print("No messages to chat about.")
84
+ return
85
+
86
+ client = make_client(max_retries=8)
87
+ system_prompt, used, dropped, start, end = prepare_chat(conversation, tokens_per_minute)
88
+
89
+ if dropped:
90
+ print(
91
+ f"Note: this conversation is large — loaded the most recent "
92
+ f"{used:,} of {len(conversation.messages):,} messages to stay within "
93
+ f"the rate limit.\n"
94
+ )
95
+
96
+ history: list[dict] = []
97
+
98
+ print(
99
+ f"Loaded {used} messages with {conversation.contact} "
100
+ f"({start} → {end}).\n"
101
+ "Ask anything about the conversation. Type 'quit' or press Ctrl+C to exit.\n"
102
+ )
103
+
104
+ while True:
105
+ try:
106
+ user_input = input("> ").strip()
107
+ except (EOFError, KeyboardInterrupt):
108
+ print()
109
+ break
110
+
111
+ if not user_input:
112
+ continue
113
+ if user_input.lower() in {"quit", "exit", "q"}:
114
+ break
115
+
116
+ history.append({"role": "user", "content": user_input})
117
+
118
+ print()
119
+ full_response = ""
120
+ for text in stream_chat(client, system_prompt, history):
121
+ print(text, end="", flush=True)
122
+ full_response += text
123
+
124
+ print("\n")
125
+ history.append({"role": "assistant", "content": full_response})
@@ -0,0 +1,112 @@
1
+ """Backfill stored per-month summaries for past months on demand.
2
+
3
+ For each past month without a stored summary (in chronological order):
4
+ load the messages for that month, gather any earlier-stored summaries as
5
+ cross-month context, run summarize(), and persist the result. The current
6
+ month is intentionally skipped (it's still growing).
7
+ """
8
+
9
+ import threading
10
+ from collections import defaultdict
11
+
12
+ from .models import Conversation, Message
13
+ from .stats import bucket_of, current_bucket
14
+ from .store import get_notes, list_summaries, save_summary
15
+ from .summarizer import EventHook, _MODEL, Cancelled, summarize
16
+
17
+
18
+ def backfill_summaries(
19
+ target_kind: str,
20
+ target_id: str,
21
+ contact_name: str,
22
+ messages: list[Message],
23
+ tokens_per_minute: int,
24
+ on_event: EventHook | None = None,
25
+ cancel_event: threading.Event | None = None,
26
+ ) -> None:
27
+ """Generate and persist any missing past-month summaries for *target*.
28
+
29
+ Emits structured events via *on_event*:
30
+ * backfill_start, total_existing, total_missing, missing_buckets
31
+ * bucket_start, bucket, index, total
32
+ * status, bucket, message (relayed from per-bucket summarize)
33
+ * bucket_done, bucket, text
34
+ * cancelled
35
+ * backfill_done
36
+ """
37
+ cur = current_bucket()
38
+
39
+ by_bucket: dict[str, list[Message]] = defaultdict(list)
40
+ for m in messages:
41
+ by_bucket[bucket_of(m.timestamp)].append(m)
42
+
43
+ existing = list_summaries(target_kind, target_id)
44
+ missing = [b for b in sorted(by_bucket) if b != cur and b not in existing]
45
+
46
+ if on_event is not None:
47
+ on_event(
48
+ {
49
+ "type": "backfill_start",
50
+ "total_existing": len(existing),
51
+ "total_missing": len(missing),
52
+ "missing_buckets": missing,
53
+ }
54
+ )
55
+
56
+ if not missing:
57
+ if on_event is not None:
58
+ on_event({"type": "backfill_done"})
59
+ return
60
+
61
+ def notes_provider() -> str:
62
+ return get_notes(target_kind, target_id)
63
+
64
+ for i, bucket in enumerate(missing, 1):
65
+ if cancel_event is not None and cancel_event.is_set():
66
+ if on_event is not None:
67
+ on_event({"type": "cancelled"})
68
+ return
69
+
70
+ if on_event is not None:
71
+ on_event(
72
+ {"type": "bucket_start", "bucket": bucket, "index": i, "total": len(missing)}
73
+ )
74
+
75
+ # Gather prior-stored summaries (chronological order, oldest first).
76
+ prior_map = list_summaries(target_kind, target_id)
77
+ priors: list[tuple[str, str]] = sorted(
78
+ (b, t) for b, t in prior_map.items() if b < bucket
79
+ )
80
+
81
+ conv = Conversation(contact=contact_name, messages=by_bucket[bucket])
82
+
83
+ # Relay only status events from the inner summarize, tagged by bucket;
84
+ # suppress per-chunk part events to avoid flooding the backfill stream.
85
+ def bucket_event_hook(ev: dict, _b: str = bucket) -> None:
86
+ if on_event is None:
87
+ return
88
+ if ev.get("type") == "status":
89
+ on_event({"type": "status", "bucket": _b, "message": ev["message"]})
90
+
91
+ try:
92
+ summary = summarize(
93
+ conv,
94
+ verbose=False,
95
+ tokens_per_minute=tokens_per_minute,
96
+ on_event=bucket_event_hook,
97
+ notes_provider=notes_provider,
98
+ cancel_event=cancel_event,
99
+ prior_summaries=priors,
100
+ )
101
+ except Cancelled:
102
+ if on_event is not None:
103
+ on_event({"type": "cancelled", "bucket": bucket})
104
+ return
105
+
106
+ save_summary(target_kind, target_id, bucket, summary, notes_provider(), _MODEL)
107
+
108
+ if on_event is not None:
109
+ on_event({"type": "bucket_done", "bucket": bucket, "text": summary})
110
+
111
+ if on_event is not None:
112
+ on_event({"type": "backfill_done"})
msg_summarizer/cli.py ADDED
@@ -0,0 +1,394 @@
1
+ import datetime
2
+ import pathlib
3
+ import sys
4
+
5
+ import anthropic
6
+ import click
7
+
8
+ from .advisor import run_chat
9
+ from .config import load_user_env
10
+ from .db import DB_PATH, fetch_messages, list_contacts
11
+ from .summarizer import _DEFAULT_TOKENS_PER_MINUTE, MissingAPIKey, summarize
12
+
13
+ # Load ~/.msg-summarizer/.env into os.environ before anything reads it.
14
+ load_user_env()
15
+
16
+
17
+ def _resolve_date_range(
18
+ days: int | None,
19
+ from_date: str | None,
20
+ to_date: str | None,
21
+ ) -> tuple[datetime.datetime | None, datetime.datetime | None]:
22
+ """Resolve --days/--from/--to into UTC bounds; any bound may be None (unbounded).
23
+
24
+ With no options at all, returns (None, None) — i.e. the full history.
25
+ """
26
+ if days is not None and (from_date or to_date):
27
+ raise click.UsageError("Use --days OR --from/--to, not both.")
28
+
29
+ if days is not None:
30
+ now = datetime.datetime.now(tz=datetime.timezone.utc)
31
+ return now - datetime.timedelta(days=days), now
32
+
33
+ start = end = None
34
+ if from_date:
35
+ start = datetime.datetime.strptime(from_date, "%Y-%m-%d").replace(
36
+ tzinfo=datetime.timezone.utc
37
+ )
38
+ if to_date:
39
+ end = datetime.datetime.strptime(to_date, "%Y-%m-%d").replace(
40
+ tzinfo=datetime.timezone.utc
41
+ ) + datetime.timedelta(days=1, seconds=-1)
42
+ return start, end
43
+
44
+
45
+ def _range_label(
46
+ start: datetime.datetime | None, end: datetime.datetime | None
47
+ ) -> str:
48
+ """Human-readable description of a (possibly open-ended) date range."""
49
+ if start is None and end is None:
50
+ return "all time"
51
+ lo = start.date().isoformat() if start else "start"
52
+ hi = end.date().isoformat() if end else "now"
53
+ return f"{lo} → {hi}"
54
+
55
+
56
+ @click.group()
57
+ def main() -> None:
58
+ """Summarize iMessage conversations using Claude AI."""
59
+
60
+
61
+ @main.command("list-contacts")
62
+ @click.option(
63
+ "--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
64
+ )
65
+ @click.option("--limit", default=50, show_default=True, help="Max contacts to show.")
66
+ def list_contacts_cmd(db_path: str | None, limit: int) -> None:
67
+ """List contacts found in the iMessage database.
68
+
69
+ Use the HANDLE ID column as the CONTACT argument to 'summarize'.
70
+ """
71
+ resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
72
+ try:
73
+ contacts = list_contacts(db_path=resolved_db, limit=limit)
74
+ except PermissionError as exc:
75
+ click.echo(f"Error: {exc}", err=True)
76
+ sys.exit(1)
77
+
78
+ if not contacts:
79
+ click.echo("No contacts found in the database.")
80
+ return
81
+
82
+ col1 = max(len(h) for h, _, _ in contacts)
83
+ col2 = max((len(d) for _, d, _ in contacts), default=0)
84
+ col1 = max(col1, 9) # min width for "HANDLE ID"
85
+ col2 = max(col2, 12) # min width for "DISPLAY NAME"
86
+
87
+ header = f"{'HANDLE ID':<{col1}} {'DISPLAY NAME':<{col2}} MESSAGES"
88
+ click.echo(header)
89
+ click.echo("-" * len(header))
90
+ for handle_id, display_name, msg_count in contacts:
91
+ click.echo(f"{handle_id:<{col1}} {display_name:<{col2}} {msg_count}")
92
+
93
+
94
+ @main.command("messages")
95
+ @click.argument("contact")
96
+ @click.option("--days", type=int, default=None, help="Only the last N days.")
97
+ @click.option(
98
+ "--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive, UTC)."
99
+ )
100
+ @click.option(
101
+ "--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive, UTC)."
102
+ )
103
+ @click.option(
104
+ "--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
105
+ )
106
+ def messages_cmd(
107
+ contact: str,
108
+ days: int | None,
109
+ from_date: str | None,
110
+ to_date: str | None,
111
+ db_path: str | None,
112
+ ) -> None:
113
+ """Print the message history with CONTACT as plain text.
114
+
115
+ CONTACT is matched as a substring against phone numbers, email addresses,
116
+ and chat names. Each line is "[local timestamp] sender: text". With no date
117
+ options the full history is printed; --days/--from/--to narrow the range.
118
+ Run 'list-contacts' first to find the right identifier.
119
+
120
+ \b
121
+ Examples:
122
+ msg-summarizer messages "+15551234567"
123
+ msg-summarizer messages "+15551234567" --days 30
124
+ msg-summarizer messages "alice@example.com" --from 2025-01-01 --to 2025-06-30
125
+ """
126
+ try:
127
+ start, end = _resolve_date_range(days, from_date, to_date)
128
+ except ValueError as exc:
129
+ raise click.UsageError(f"Invalid date format: {exc}") from exc
130
+
131
+ resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
132
+
133
+ click.echo(
134
+ f"Loading messages with '{contact}' ({_range_label(start, end)})…", err=True
135
+ )
136
+
137
+ try:
138
+ conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
139
+ except PermissionError as exc:
140
+ click.echo(f"Error: {exc}", err=True)
141
+ sys.exit(1)
142
+
143
+ if not conversation.messages:
144
+ click.echo(
145
+ "No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
146
+ err=True,
147
+ )
148
+ sys.exit(0)
149
+
150
+ for msg in conversation.messages:
151
+ local_ts = msg.timestamp.astimezone()
152
+ click.echo(f"[{local_ts:%Y-%m-%d %H:%M}] {msg.sender}: {msg.text}")
153
+
154
+
155
+ @main.command("summarize")
156
+ @click.argument("contact")
157
+ @click.option("--days", type=int, default=None, help="Summarize the last N days.")
158
+ @click.option(
159
+ "--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive)."
160
+ )
161
+ @click.option(
162
+ "--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive)."
163
+ )
164
+ @click.option(
165
+ "--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
166
+ )
167
+ @click.option(
168
+ "--tokens-per-minute",
169
+ "tokens_per_minute",
170
+ type=int,
171
+ default=_DEFAULT_TOKENS_PER_MINUTE,
172
+ show_default=True,
173
+ help="Your account's input-tokens/min rate limit. Chunks are sized and paced "
174
+ "to stay under it; raise it if you're on a higher tier.",
175
+ )
176
+ @click.option("--verbose", is_flag=True, help="Show message count and cache stats.")
177
+ def summarize_cmd(
178
+ contact: str,
179
+ days: int | None,
180
+ from_date: str | None,
181
+ to_date: str | None,
182
+ db_path: str | None,
183
+ tokens_per_minute: int,
184
+ verbose: bool,
185
+ ) -> None:
186
+ """Summarize iMessage conversations with CONTACT using Claude AI.
187
+
188
+ CONTACT is matched as a substring against phone numbers, email addresses,
189
+ and chat names in the iMessage database. Run 'list-contacts' first to find
190
+ the right identifier. With no date options, the entire history is
191
+ summarized; --days/--from/--to narrow the range.
192
+
193
+ \b
194
+ Examples:
195
+ msg-summarizer summarize "+15551234567"
196
+ msg-summarizer summarize "+15551234567" --days 7
197
+ msg-summarizer summarize "alice@example.com" --from 2024-12-01 --to 2024-12-31
198
+ """
199
+ try:
200
+ start, end = _resolve_date_range(days, from_date, to_date)
201
+ except ValueError as exc:
202
+ raise click.UsageError(f"Invalid date format: {exc}") from exc
203
+
204
+ resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
205
+
206
+ click.echo(
207
+ f"Fetching messages with '{contact}' ({_range_label(start, end)})…",
208
+ err=True,
209
+ )
210
+
211
+ try:
212
+ conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
213
+ except PermissionError as exc:
214
+ click.echo(f"Error: {exc}", err=True)
215
+ sys.exit(1)
216
+
217
+ if not conversation.messages:
218
+ click.echo(
219
+ "No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
220
+ err=True,
221
+ )
222
+ sys.exit(0)
223
+
224
+ if verbose:
225
+ click.echo(f"Found {len(conversation.messages)} messages. Summarizing…", err=True)
226
+ else:
227
+ click.echo("Summarizing…", err=True)
228
+
229
+ try:
230
+ summary = summarize(conversation, verbose=verbose, tokens_per_minute=tokens_per_minute)
231
+ except MissingAPIKey as exc:
232
+ click.echo(f"Error: {exc}", err=True)
233
+ sys.exit(1)
234
+ except anthropic.APIError as exc:
235
+ click.echo(f"\nError from the Claude API: {exc}", err=True)
236
+ click.echo(
237
+ "Any completed parts were saved to the temp file noted above. "
238
+ "Try a narrower range (--days/--from/--to), a lower --tokens-per-minute, "
239
+ "or raise your tier.",
240
+ err=True,
241
+ )
242
+ sys.exit(1)
243
+ click.echo(summary)
244
+
245
+
246
+ @main.command("chat")
247
+ @click.argument("contact")
248
+ @click.option("--days", type=int, default=None, help="Load the last N days of messages.")
249
+ @click.option(
250
+ "--from", "from_date", default=None, metavar="YYYY-MM-DD", help="Start date (inclusive)."
251
+ )
252
+ @click.option(
253
+ "--to", "to_date", default=None, metavar="YYYY-MM-DD", help="End date (inclusive)."
254
+ )
255
+ @click.option(
256
+ "--db", "db_path", default=None, help=f"Path to chat.db (default: {DB_PATH})."
257
+ )
258
+ @click.option(
259
+ "--tokens-per-minute",
260
+ "tokens_per_minute",
261
+ type=int,
262
+ default=_DEFAULT_TOKENS_PER_MINUTE,
263
+ show_default=True,
264
+ help="Your account's input-tokens/min rate limit. A large history is trimmed "
265
+ "to the most recent messages that fit under it.",
266
+ )
267
+ def chat_cmd(
268
+ contact: str,
269
+ days: int | None,
270
+ from_date: str | None,
271
+ to_date: str | None,
272
+ db_path: str | None,
273
+ tokens_per_minute: int,
274
+ ) -> None:
275
+ """Chat interactively about a conversation — ask questions, get advice, draft replies.
276
+
277
+ With no date options, the entire history is loaded; --days/--from/--to
278
+ narrow the range.
279
+
280
+ \b
281
+ Examples:
282
+ msg-summarizer chat "+15551234567"
283
+ msg-summarizer chat "+15551234567" --days 30
284
+ msg-summarizer chat "alice@example.com" --from 2025-01-01 --to 2025-05-27
285
+ """
286
+ try:
287
+ start, end = _resolve_date_range(days, from_date, to_date)
288
+ except ValueError as exc:
289
+ raise click.UsageError(f"Invalid date format: {exc}") from exc
290
+
291
+ resolved_db = pathlib.Path(db_path) if db_path else DB_PATH
292
+
293
+ click.echo(
294
+ f"Loading messages with '{contact}' ({_range_label(start, end)})…",
295
+ err=True,
296
+ )
297
+
298
+ try:
299
+ conversation = fetch_messages(contact, db_path=resolved_db, start=start, end=end)
300
+ except PermissionError as exc:
301
+ click.echo(f"Error: {exc}", err=True)
302
+ sys.exit(1)
303
+
304
+ if not conversation.messages:
305
+ click.echo(
306
+ "No messages found. Run 'msg-summarizer list-contacts' to see available identifiers.",
307
+ err=True,
308
+ )
309
+ sys.exit(0)
310
+
311
+ try:
312
+ run_chat(conversation, tokens_per_minute=tokens_per_minute)
313
+ except MissingAPIKey as exc:
314
+ click.echo(f"Error: {exc}", err=True)
315
+ sys.exit(1)
316
+ except anthropic.APIError as exc:
317
+ click.echo(f"\nError from the Claude API: {exc}", err=True)
318
+ sys.exit(1)
319
+
320
+
321
+ @main.command("setup")
322
+ def setup_cmd() -> None:
323
+ """Walk through first-run setup: API key + Full Disk Access check."""
324
+ import sqlite3
325
+
326
+ from . import config
327
+ from .db import DB_PATH
328
+
329
+ click.echo("msg-summarizer setup")
330
+ click.echo("====================\n")
331
+
332
+ # --- API key ---
333
+ have_key = bool(
334
+ __import__("os").environ.get("ANTHROPIC_API_KEY")
335
+ or __import__("os").environ.get("ANTHROPIC_AUTH_TOKEN")
336
+ )
337
+ if have_key:
338
+ click.echo("✓ Anthropic API key already set in the environment.")
339
+ else:
340
+ click.echo("Anthropic API key not set.")
341
+ click.echo("Get one at https://console.anthropic.com/")
342
+ key = click.prompt("Paste your key (or press Enter to skip)", default="", show_default=False)
343
+ key = key.strip()
344
+ if not key:
345
+ click.echo(" Skipped. Summary and Chat will be disabled until set.")
346
+ elif not key.startswith(("sk-ant-", "sk-")):
347
+ click.echo(" That doesn't look like an Anthropic key (expected to start with sk-ant-).")
348
+ click.echo(" Skipped.")
349
+ else:
350
+ try:
351
+ config.save_env_var("ANTHROPIC_API_KEY", key)
352
+ click.echo(f"✓ Saved to {config.USER_ENV_FILE} (mode 0600).")
353
+ except OSError as exc:
354
+ click.echo(f" Couldn't write: {exc}")
355
+ click.echo("")
356
+
357
+ # --- Full Disk Access ---
358
+ fda_ok = False
359
+ try:
360
+ sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True).close()
361
+ fda_ok = True
362
+ except sqlite3.OperationalError:
363
+ pass
364
+
365
+ if fda_ok:
366
+ click.echo(f"✓ Full Disk Access OK — {DB_PATH} is readable.")
367
+ else:
368
+ click.echo("✗ Can't open ~/Library/Messages/chat.db.")
369
+ click.echo(" Grant Full Disk Access to the app you'll run msg-summarizer from:")
370
+ click.echo(" 1. System Settings → Privacy & Security → Full Disk Access.")
371
+ click.echo(" 2. Click + and add your terminal app (Terminal, iTerm, Warp, VS Code, …).")
372
+ click.echo(" 3. Quit and reopen that app, then run msg-summarizer again.")
373
+ click.echo("")
374
+
375
+ if have_key or fda_ok:
376
+ click.echo("Done. Try `msg-summarizer serve` to launch the app.")
377
+
378
+
379
+ @main.command("serve")
380
+ @click.option("--host", default="127.0.0.1", show_default=True, help="Host to bind.")
381
+ @click.option("--port", default=8765, show_default=True, type=int, help="Port to bind.")
382
+ @click.option("--no-browser", is_flag=True, help="Don't open a browser automatically.")
383
+ def serve_cmd(host: str, port: int, no_browser: bool) -> None:
384
+ """Launch the web app (API + UI) and open it in your browser."""
385
+ import threading
386
+ import webbrowser
387
+
388
+ import uvicorn
389
+
390
+ url = f"http://{host}:{port}"
391
+ if not no_browser:
392
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
393
+ click.echo(f"Serving msg-summarizer at {url} (Ctrl+C to stop)…", err=True)
394
+ uvicorn.run("msg_summarizer.web.app:app", host=host, port=port, log_level="info")
@@ -0,0 +1,57 @@
1
+ """User-level configuration: ~/.msg-summarizer/.env for persistent settings.
2
+
3
+ Loading order (highest priority wins, anything already set is kept):
4
+ 1. Existing environment variables (explicit `export` or shell config).
5
+ 2. ~/.msg-summarizer/.env (the set-it-and-forget-it persistent store).
6
+
7
+ So a user can `export ANTHROPIC_API_KEY=...` for a one-off override without
8
+ touching the file, and the UI can write to the file once for "next time".
9
+ """
10
+
11
+ import os
12
+ import pathlib
13
+
14
+ from dotenv import load_dotenv
15
+
16
+ USER_CONFIG_DIR = pathlib.Path.home() / ".msg-summarizer"
17
+ USER_ENV_FILE = USER_CONFIG_DIR / ".env"
18
+
19
+
20
+ def load_user_env() -> None:
21
+ """Merge ~/.msg-summarizer/.env into os.environ; existing vars take priority."""
22
+ if USER_ENV_FILE.exists():
23
+ load_dotenv(USER_ENV_FILE, override=False)
24
+
25
+
26
+ def save_env_var(key: str, value: str) -> None:
27
+ """Persist a key=value pair to ~/.msg-summarizer/.env and update os.environ now.
28
+
29
+ Replaces an existing line for the same key (or appends one if absent).
30
+ Creates the file with mode 0600 the first time — it holds secrets.
31
+ """
32
+ if not key.replace("_", "").isalnum():
33
+ raise ValueError(f"invalid env key: {key!r}")
34
+
35
+ USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
36
+ lines: list[str] = USER_ENV_FILE.read_text().splitlines() if USER_ENV_FILE.exists() else []
37
+
38
+ found = False
39
+ out: list[str] = []
40
+ for line in lines:
41
+ stripped = line.strip()
42
+ if "=" in stripped and not stripped.startswith("#"):
43
+ existing_key = stripped.split("=", 1)[0].strip()
44
+ if existing_key == key:
45
+ out.append(f"{key}={value}")
46
+ found = True
47
+ continue
48
+ out.append(line)
49
+ if not found:
50
+ out.append(f"{key}={value}")
51
+
52
+ USER_ENV_FILE.write_text("\n".join(out) + "\n")
53
+ try:
54
+ USER_ENV_FILE.chmod(0o600)
55
+ except OSError:
56
+ pass # filesystem may not support chmod (e.g. exFAT); best-effort
57
+ os.environ[key] = value