minizen 0.1.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.
minizen-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HieuDao-code
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
minizen-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: minizen
3
+ Version: 0.1.0
4
+ Summary: A quieter way to stay informed
5
+ Author: Hieu Dao
6
+ Author-email: Hieu Dao <106801182+HieuDao-code@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Dist: miniflux>=1.1.6,<2.0.0
20
+ Requires-Dist: mistune>=3.2.0,<4.0.0
21
+ Requires-Dist: pydantic>=2.13.1,<3.0.0
22
+ Requires-Dist: pydantic-ai-slim[anthropic,openai]>=1.84.0,<2.0.0
23
+ Requires-Dist: python-dotenv>=1.1.0,<2.0.0
24
+ Requires-Dist: tomli-w>=1.0.0,<2.0.0
25
+ Requires-Dist: typer>=0.24.1,<0.25.0
26
+ Requires-Python: >=3.14
27
+ Project-URL: Homepage, https://github.com/hieudao-code/minizen
28
+ Project-URL: Documentation, https://hieudao-code.github.io/minizen/
29
+ Project-URL: Repository, https://github.com/hieudao-code/minizen
30
+ Project-URL: Issues, https://github.com/hieudao-code/minizen/issues
31
+ Project-URL: Changelog, https://github.com/hieudao-code/minizen/releases
32
+ Description-Content-Type: text/markdown
33
+
34
+ <h1 align="center">
35
+ <p align="center">
36
+ <span style="font-size: 80px; font-weight: bold;">mini</span>
37
+ <span style="font-size: 80px; font-weight: bold; color: #F27D3E;">zen</span>
38
+ </p>
39
+ </h1>
40
+ <p align="center">A quieter way to stay informed.</p>
41
+ <p align="center">
42
+ <a href="https://hieudao-code.github.io/minizen/">Documentation</a> | <a href="https://hieudao-code.github.io/minizen/getting_started/">Getting Started</a> | <a href="https://hieudao-code.github.io/minizen/configuration/">Configuration</a>
43
+ </p>
44
+
45
+ ## About
46
+
47
+ **minizen** fetches your unread RSS articles from [Miniflux](https://miniflux.app),
48
+ uses AI to curate and summarise the most interesting ones, and emails you a clean daily digest.
49
+
50
+ ### Features
51
+
52
+ - **Curated, not firehosed** — the AI picks your top N articles and writes a cohesive
53
+ narrative, not a bullet dump
54
+ - **Runs on a schedule** — ships with a GitHub Actions workflow for a hands-free daily digest,
55
+ no server required
56
+ - **Dry-run friendly** — preview the digest in your terminal before a single email is sent
57
+ - **Pluggable AI** — works with Anthropic Claude or OpenAI models via
58
+ [pydantic-ai](https://ai.pydantic.dev/)
59
+
60
+ ## Quick start
61
+
62
+ ```bash
63
+ uv tool install minizen
64
+ minizen setup # interactive wizard — configure Miniflux, AI, and email
65
+ minizen digest preview # preview today's digest in your terminal
66
+ minizen run # fetch → summarise → send
67
+ ```
@@ -0,0 +1,34 @@
1
+ <h1 align="center">
2
+ <p align="center">
3
+ <span style="font-size: 80px; font-weight: bold;">mini</span>
4
+ <span style="font-size: 80px; font-weight: bold; color: #F27D3E;">zen</span>
5
+ </p>
6
+ </h1>
7
+ <p align="center">A quieter way to stay informed.</p>
8
+ <p align="center">
9
+ <a href="https://hieudao-code.github.io/minizen/">Documentation</a> | <a href="https://hieudao-code.github.io/minizen/getting_started/">Getting Started</a> | <a href="https://hieudao-code.github.io/minizen/configuration/">Configuration</a>
10
+ </p>
11
+
12
+ ## About
13
+
14
+ **minizen** fetches your unread RSS articles from [Miniflux](https://miniflux.app),
15
+ uses AI to curate and summarise the most interesting ones, and emails you a clean daily digest.
16
+
17
+ ### Features
18
+
19
+ - **Curated, not firehosed** — the AI picks your top N articles and writes a cohesive
20
+ narrative, not a bullet dump
21
+ - **Runs on a schedule** — ships with a GitHub Actions workflow for a hands-free daily digest,
22
+ no server required
23
+ - **Dry-run friendly** — preview the digest in your terminal before a single email is sent
24
+ - **Pluggable AI** — works with Anthropic Claude or OpenAI models via
25
+ [pydantic-ai](https://ai.pydantic.dev/)
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ uv tool install minizen
31
+ minizen setup # interactive wizard — configure Miniflux, AI, and email
32
+ minizen digest preview # preview today's digest in your terminal
33
+ minizen run # fetch → summarise → send
34
+ ```
@@ -0,0 +1,168 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.7,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "minizen"
7
+ version = "0.1.0"
8
+ description = "A quieter way to stay informed"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.14"
13
+ authors = [
14
+ { name = "Hieu Dao", email = "106801182+HieuDao-code@users.noreply.github.com" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: End Users/Desktop",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+ dependencies = [
29
+ "miniflux>=1.1.6,<2.0.0",
30
+ "mistune>=3.2.0,<4.0.0",
31
+ "pydantic>=2.13.1,<3.0.0",
32
+ "pydantic-ai-slim[anthropic,openai]>=1.84.0,<2.0.0",
33
+ "python-dotenv>=1.1.0,<2.0.0",
34
+ "tomli-w>=1.0.0,<2.0.0",
35
+ "typer>=0.24.1,<0.25.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/hieudao-code/minizen"
40
+ Documentation = "https://hieudao-code.github.io/minizen/"
41
+ Repository = "https://github.com/hieudao-code/minizen"
42
+ Issues = "https://github.com/hieudao-code/minizen/issues"
43
+ Changelog = "https://github.com/hieudao-code/minizen/releases"
44
+
45
+ [project.scripts]
46
+ minizen = "minizen.main:app"
47
+ mz = "minizen.main:app"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ { include-group = "docs" },
52
+ { include-group = "lint" },
53
+ { include-group = "miscellaneous" },
54
+ { include-group = "release" },
55
+ { include-group = "security" },
56
+ { include-group = "test" },
57
+ { include-group = "typing" },
58
+ ]
59
+ docs = ["zensical==0.0.32"]
60
+ lint = ["ruff==0.15.10"]
61
+ miscellaneous = ["prek==0.3.9"]
62
+ release = ["commitizen==4.13.10"]
63
+ security = ["zizmor==1.24.1"]
64
+ test = ["pytest==9.0.3", "pytest-cov==7.1.0", "pytest-mock==3.15.1"]
65
+ typing = ["ty==0.0.29"]
66
+
67
+ [tool.uv]
68
+ exclude-newer = "1 week"
69
+
70
+ [tool.commitizen]
71
+ name = "cz_conventional_commits"
72
+ tag_format = "$version"
73
+ version_scheme = "pep440"
74
+ version_provider = "uv"
75
+ update_changelog_on_bump = true
76
+ breaking_change_exclamation_in_title = true
77
+ version_files = ["src/minizen/__init__.py"]
78
+ major_version_zero = true
79
+
80
+ [tool.ruff]
81
+ target-version = "py314"
82
+
83
+ [tool.ruff.lint]
84
+ select = [
85
+ "A", # flake8-builtins
86
+ "ASYNC", # flake8-async
87
+ "B", # flake8-bugbear
88
+ "C4", # flake8-comprehensions
89
+ "C90", # mccabe complexity
90
+ "E", # pycodestyle error
91
+ "F", # pyflakes
92
+ "FAST", # fastapi
93
+ "I", # isort
94
+ "N", # pep8-naming
95
+ "PERF", # Perflint
96
+ "PIE", # flake8-pie
97
+ "PLR", # pylint refactor
98
+ "PT", # flake8-pytest-style
99
+ "PYI", # flake8-pyi
100
+ "RUF", # ruff
101
+ "S", # flake8-bandit
102
+ "SIM", # flake8-simplify
103
+ "UP", # pyupgrade
104
+ "W", # pycodestyle warnings
105
+ ]
106
+ ignore = [
107
+ "B904", # raise-without-from-inside-except
108
+ ]
109
+
110
+ [tool.ruff.lint.per-file-ignores]
111
+ "**/tests/**" = [
112
+ "PLR2004", # magic-value-comparison
113
+ "PLR0913", # too-many-arguments
114
+ "S101", # assert
115
+ "S105", # hardcoded-password-string
116
+ "S106", # hardcoded-password-func-arg
117
+ ]
118
+ "src/minizen/cli/commands/setup.py" = ["PLR0913"]
119
+ "src/minizen/cli/commands/run.py" = ["PLR0913"]
120
+ "src/minizen/providers/email/template.py" = ["E501"]
121
+
122
+ [tool.ruff.lint.isort]
123
+ known-first-party = ["minizen"]
124
+ force-wrap-aliases = true
125
+ combine-as-imports = true
126
+
127
+ [tool.ruff.format]
128
+ docstring-code-format = true
129
+
130
+ [tool.coverage.run]
131
+ branch = true
132
+ source = ["minizen"]
133
+ omit = ["*/minizen/main.py"]
134
+
135
+ [tool.pytest.ini_options]
136
+ addopts = "--cov --cov-report=term-missing --cov-fail-under=100"
137
+ testpaths = ["tests"]
138
+
139
+ [tool.tox]
140
+ env_list = ["py314", "commit-msg", "lint", "security", "typing"]
141
+
142
+ [tool.tox.env_run_base]
143
+ description = "Run tests"
144
+ dependency_groups = ["dev"]
145
+ commands = [["pytest"]]
146
+
147
+ [tool.tox.env.commit-msg]
148
+ description = "Run commitizen checks"
149
+ skip_install = true
150
+ dependency_groups = ["release"]
151
+ commands = [["cz", "check", "--rev-range", "origin/main..HEAD"]]
152
+
153
+ [tool.tox.env.lint]
154
+ description = "Run linters"
155
+ skip_install = true
156
+ dependency_groups = ["lint"]
157
+ commands = [["ruff", "check"], ["ruff", "format", "--check"]]
158
+
159
+ [tool.tox.env.security]
160
+ description = "Run security checks"
161
+ skip_install = true
162
+ dependency_groups = ["security"]
163
+ commands = [["uv", "audit"], ["zizmor", '.', "--persona", "auditor"]]
164
+
165
+ [tool.tox.env.typing]
166
+ description = "Run type checks"
167
+ dependency_groups = ["test", "typing"]
168
+ commands = [["ty", "check"]]
@@ -0,0 +1,29 @@
1
+ """minizen — A quieter way to stay informed."""
2
+
3
+ from minizen.ai import DigestAgent, DigestResult
4
+ from minizen.config import (
5
+ AIConfig,
6
+ EmailConfig,
7
+ MinifluxConfig,
8
+ Settings,
9
+ load_settings,
10
+ )
11
+ from minizen.core import run_pipeline
12
+ from minizen.providers.email import EmailProvider
13
+ from minizen.providers.rss import Article, MinifluxProvider
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "AIConfig",
19
+ "Article",
20
+ "DigestAgent",
21
+ "DigestResult",
22
+ "EmailConfig",
23
+ "EmailProvider",
24
+ "MinifluxConfig",
25
+ "MinifluxProvider",
26
+ "Settings",
27
+ "load_settings",
28
+ "run_pipeline",
29
+ ]
@@ -0,0 +1,5 @@
1
+ """Public AI agent API for minizen."""
2
+
3
+ from minizen.ai.agent import DigestAgent, DigestResult
4
+
5
+ __all__ = ["DigestAgent", "DigestResult"]
@@ -0,0 +1,74 @@
1
+ import logging
2
+ from typing import cast
3
+
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_ai import Agent, AgentRunResult
6
+
7
+ from minizen.providers.rss.miniflux import Article
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _SYSTEM_PROMPT = """\
12
+ You are a personal news curator. You receive a list of unread articles and must:
13
+ 1. Select the top N most important and interesting articles.
14
+ 2. Write a cohesive, well-structured Markdown digest covering those articles.
15
+ 3. Return the digest and the IDs of the articles you selected.
16
+
17
+ Each article section must include a direct link to the original article URL.
18
+ Be concise. Prioritise articles with broad significance over niche topics.
19
+ """
20
+
21
+
22
+ class DigestResult(BaseModel):
23
+ """Structured output from the AI digest agent."""
24
+
25
+ markdown: str = Field(description="Markdown digest text produced by the agent.")
26
+ articles_used: list[int] = Field(
27
+ description="IDs of the articles selected for the digest."
28
+ )
29
+
30
+
31
+ class DigestAgent:
32
+ """AI-powered agent that selects and summarises articles into a Markdown digest."""
33
+
34
+ def __init__(self, *, model: str, top_n: int) -> None:
35
+ """Initialise the agent with the given model and article limit.
36
+
37
+ Args:
38
+ model: pydantic-ai model identifier (e.g. ``anthropic:claude-haiku-4-5``).
39
+ top_n: Maximum number of articles to include in the digest.
40
+ """
41
+ logger.debug("Initialising DigestAgent: model=%s, top_n=%d", model, top_n)
42
+ self._top_n = top_n
43
+ self._agent = Agent(
44
+ model=model,
45
+ output_type=DigestResult,
46
+ system_prompt=_SYSTEM_PROMPT,
47
+ )
48
+
49
+ def run(self, *, articles: list[Article]) -> DigestResult:
50
+ """Select the top N articles and return a structured Markdown digest.
51
+
52
+ Args:
53
+ articles: Full list of unread articles to choose from.
54
+
55
+ Returns:
56
+ A ``DigestResult`` containing the Markdown text and the IDs of
57
+ articles that were included.
58
+ """
59
+ logger.info("Running AI agent on %d article(s)", len(articles))
60
+ articles_text = "\n\n---\n\n".join(
61
+ f"ID: {a.id}\n"
62
+ f"Feed: {a.feed_name}\n"
63
+ f"Title: {a.title}\n"
64
+ f"URL: {a.url}\n"
65
+ f"Published: {a.published_at.isoformat()}\n\n"
66
+ f"{a.content}"
67
+ for a in articles
68
+ )
69
+ user_prompt = (
70
+ f"Please select the top {self._top_n} most important articles " # noqa: S608
71
+ f"from the following and write a digest:\n\n{articles_text}"
72
+ )
73
+ result = self._agent.run_sync(user_prompt)
74
+ return cast(AgentRunResult[DigestResult], result).output
@@ -0,0 +1,34 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from minizen.cli.commands import (
6
+ config as config_module,
7
+ digest as digest_module,
8
+ )
9
+ from minizen.cli.commands.run import run
10
+ from minizen.cli.commands.setup import setup
11
+ from minizen.cli.state import configure_logging
12
+
13
+ app = typer.Typer(
14
+ name="minizen",
15
+ help="A quieter way to stay informed.",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+
20
+ @app.callback()
21
+ def _callback(
22
+ verbose: Annotated[
23
+ bool,
24
+ typer.Option("--verbose", "-v", help="Enable debug logging."),
25
+ ] = False,
26
+ ) -> None:
27
+ """Configure logging for the CLI session."""
28
+ configure_logging(verbose=verbose)
29
+
30
+
31
+ app.command("run")(run)
32
+ app.command("setup")(setup)
33
+ app.add_typer(config_module.app, name="config")
34
+ app.add_typer(digest_module.app, name="digest")
File without changes
@@ -0,0 +1,105 @@
1
+ import contextlib
2
+ import tomllib
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import tomli_w
7
+ import typer
8
+
9
+ from minizen.config.loader import load_settings
10
+
11
+ _DEFAULT_CONFIG = Path.home() / ".config" / "minizen" / "config.toml"
12
+
13
+ _CONFIG_OPTION = Annotated[
14
+ Path,
15
+ typer.Option(help="Path to the TOML configuration file.", show_default=True),
16
+ ]
17
+
18
+ app = typer.Typer(help="Inspect and update configuration.")
19
+
20
+
21
+ @app.command("show")
22
+ def show(config: _CONFIG_OPTION = _DEFAULT_CONFIG) -> None:
23
+ """Display the current configuration."""
24
+ try:
25
+ with open(config, "rb") as f:
26
+ data = tomllib.load(f)
27
+ except FileNotFoundError:
28
+ typer.echo(f"Config file not found: {config}")
29
+ typer.echo("Run `minizen setup` to create one.")
30
+ raise typer.Exit(code=1)
31
+
32
+ typer.echo(f"Config file: {config}")
33
+ mf = data.get("miniflux", {})
34
+ em = data.get("email", {})
35
+ ai = data.get("ai", {})
36
+ typer.echo(f" miniflux.url: {mf.get('url', '(unset)')}")
37
+ typer.echo(f" miniflux.api_key: {'(from env)' if mf.get('url') else '(unset)'}")
38
+ typer.echo(f" email.smtp_host: {em.get('smtp_host', '(unset)')}")
39
+ typer.echo(f" email.smtp_port: {em.get('smtp_port', '(unset)')}")
40
+ typer.echo(f" email.from_addr: {em.get('from_addr', '(unset)')}")
41
+ typer.echo(f" email.to_addr: {em.get('to_addr', '(unset)')}")
42
+ typer.echo(f" ai.model: {ai.get('model', 'anthropic:claude-haiku-4-5')}")
43
+ typer.echo(f" ai.top_n: {ai.get('top_n', 5)}")
44
+
45
+
46
+ @app.command("validate")
47
+ def validate(config: _CONFIG_OPTION = _DEFAULT_CONFIG) -> None:
48
+ """Validate the configuration file and environment variables."""
49
+ try:
50
+ load_settings(config_path=config)
51
+ except FileNotFoundError:
52
+ typer.echo(f"Config file not found: {config}")
53
+ typer.echo("Run `minizen setup` to create one.")
54
+ raise typer.Exit(code=1)
55
+ except KeyError as e:
56
+ typer.echo(f"Error: missing environment variable {e.args[0]}")
57
+ raise typer.Exit(code=1)
58
+ typer.echo("Configuration is valid.")
59
+
60
+
61
+ _ALLOWED_KEYS = {
62
+ "miniflux.url",
63
+ "ai.model",
64
+ "ai.top_n",
65
+ "email.smtp_host",
66
+ "email.smtp_port",
67
+ "email.from_addr",
68
+ "email.to_addr",
69
+ }
70
+
71
+
72
+ @app.command("set")
73
+ def set_value(
74
+ key: Annotated[
75
+ str, typer.Argument(help="Dot-separated config key (e.g. ai.top_n).")
76
+ ],
77
+ value: Annotated[str, typer.Argument(help="New value.")],
78
+ config: _CONFIG_OPTION = _DEFAULT_CONFIG,
79
+ ) -> None:
80
+ """Set a configuration value in the TOML file."""
81
+ if key not in _ALLOWED_KEYS:
82
+ allowed = ", ".join(sorted(_ALLOWED_KEYS))
83
+ typer.echo(f"Error: unknown config key '{key}'. Allowed: {allowed}")
84
+ raise typer.Exit(code=1)
85
+
86
+ try:
87
+ with open(config, "rb") as f:
88
+ data = tomllib.load(f)
89
+ except FileNotFoundError:
90
+ typer.echo(f"Config file not found: {config}")
91
+ typer.echo("Run `minizen setup` to create one.")
92
+ raise typer.Exit(code=1)
93
+
94
+ section, field = key.split(".", 1)
95
+ if section not in data:
96
+ data[section] = {}
97
+
98
+ # Coerce numeric strings to int
99
+ coerced: str | int = value
100
+ with contextlib.suppress(ValueError):
101
+ coerced = int(value)
102
+
103
+ data[section][field] = coerced
104
+ config.write_bytes(tomli_w.dumps(data).encode())
105
+ typer.echo(f"Set {key} = {coerced!r}")
@@ -0,0 +1,135 @@
1
+ from datetime import date
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from minizen.ai.agent import DigestAgent
8
+ from minizen.cli.state import configure_logging
9
+ from minizen.config.loader import load_settings
10
+ from minizen.config.models import Settings
11
+ from minizen.providers.email.smtp import EmailProvider
12
+ from minizen.providers.email.template import render_email
13
+ from minizen.providers.rss.miniflux import MinifluxProvider
14
+
15
+ _DEFAULT_CONFIG = Path.home() / ".config" / "minizen" / "config.toml"
16
+
17
+ _CONFIG_OPTION = Annotated[
18
+ Path,
19
+ typer.Option(help="Path to the TOML configuration file.", show_default=True),
20
+ ]
21
+
22
+ _VERBOSE_OPTION = Annotated[
23
+ bool,
24
+ typer.Option("--verbose", "-v", help="Enable debug logging."),
25
+ ]
26
+
27
+ _DRY_RUN_OPTION = Annotated[
28
+ bool,
29
+ typer.Option(
30
+ "--dry-run",
31
+ help="Fetch articles but skip LLM call and any external sends.",
32
+ ),
33
+ ]
34
+
35
+ app = typer.Typer(help="Preview or test the digest without marking articles as read.")
36
+
37
+
38
+ def _load(config: Path) -> Settings:
39
+ """Load settings from a TOML file, exiting with an error message on failure.
40
+
41
+ Args:
42
+ config: Path to the TOML configuration file.
43
+
44
+ Returns:
45
+ Fully loaded application settings.
46
+ """
47
+ try:
48
+ return load_settings(config_path=config)
49
+ except FileNotFoundError:
50
+ typer.echo(f"Config file not found: {config}")
51
+ typer.echo("Run `minizen setup` to create one.")
52
+ raise typer.Exit(code=1)
53
+ except KeyError as e:
54
+ typer.echo(f"Error: missing environment variable {e.args[0]}")
55
+ raise typer.Exit(code=1)
56
+
57
+
58
+ @app.command("fetch")
59
+ def fetch(
60
+ config: _CONFIG_OPTION = _DEFAULT_CONFIG,
61
+ verbose: _VERBOSE_OPTION = False,
62
+ ) -> None:
63
+ """Fetch unread articles and print their titles and URLs."""
64
+ configure_logging(verbose=verbose)
65
+ settings = _load(config)
66
+ rss = MinifluxProvider(config=settings.miniflux)
67
+ articles = rss.fetch_unread()
68
+ if not articles:
69
+ typer.echo("No unread articles.")
70
+ return
71
+ typer.echo(f"{len(articles)} unread article(s):\n")
72
+ for article in articles:
73
+ typer.echo(f"[{article.feed_name}] {article.title}")
74
+ typer.echo(f" {article.url}")
75
+
76
+
77
+ @app.command("preview")
78
+ def preview(
79
+ config: _CONFIG_OPTION = _DEFAULT_CONFIG,
80
+ verbose: _VERBOSE_OPTION = False,
81
+ dry_run: _DRY_RUN_OPTION = False,
82
+ ) -> None:
83
+ """Fetch and summarise articles, then print the Markdown digest."""
84
+ configure_logging(verbose=verbose)
85
+ settings = _load(config)
86
+ rss = MinifluxProvider(config=settings.miniflux)
87
+ articles = rss.fetch_unread()
88
+ if not articles:
89
+ typer.echo("No unread articles.")
90
+ return
91
+ if dry_run:
92
+ typer.echo(f"{len(articles)} unread article(s):\n")
93
+ for article in articles:
94
+ typer.echo(f"[{article.feed_name}] {article.title}")
95
+ typer.echo(f" {article.url}")
96
+ return
97
+ agent = DigestAgent(model=settings.ai.model, top_n=settings.ai.top_n)
98
+ result = agent.run(articles=articles)
99
+ typer.echo(result.markdown)
100
+
101
+
102
+ @app.command("send-test")
103
+ def send_test(
104
+ config: _CONFIG_OPTION = _DEFAULT_CONFIG,
105
+ verbose: _VERBOSE_OPTION = False,
106
+ dry_run: _DRY_RUN_OPTION = False,
107
+ ) -> None:
108
+ """Send a test digest email without marking articles as read."""
109
+ configure_logging(verbose=verbose)
110
+ settings = _load(config)
111
+ rss = MinifluxProvider(config=settings.miniflux)
112
+ articles = rss.fetch_unread()
113
+ if not articles:
114
+ typer.echo("No unread articles.")
115
+ return
116
+ if dry_run:
117
+ typer.confirm(
118
+ "This will make a real LLM API call but will not send an email. Continue?",
119
+ abort=True,
120
+ )
121
+ agent = DigestAgent(model=settings.ai.model, top_n=settings.ai.top_n)
122
+ result = agent.run(articles=articles)
123
+ html, plain_text = render_email(result.markdown)
124
+ if dry_run:
125
+ typer.echo("Dry run — email not sent:\n")
126
+ typer.echo(plain_text)
127
+ return
128
+ today = date.today().strftime("%B %-d, %Y")
129
+ email = EmailProvider(config=settings.email)
130
+ email.send(
131
+ subject=f"[TEST] Your Daily Zen — {today}",
132
+ html=html,
133
+ plain_text=plain_text,
134
+ )
135
+ typer.echo("Test digest sent.")