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 +21 -0
- minizen-0.1.0/PKG-INFO +67 -0
- minizen-0.1.0/README.md +34 -0
- minizen-0.1.0/pyproject.toml +168 -0
- minizen-0.1.0/src/minizen/__init__.py +29 -0
- minizen-0.1.0/src/minizen/ai/__init__.py +5 -0
- minizen-0.1.0/src/minizen/ai/agent.py +74 -0
- minizen-0.1.0/src/minizen/cli/__init__.py +34 -0
- minizen-0.1.0/src/minizen/cli/commands/__init__.py +0 -0
- minizen-0.1.0/src/minizen/cli/commands/config.py +105 -0
- minizen-0.1.0/src/minizen/cli/commands/digest.py +135 -0
- minizen-0.1.0/src/minizen/cli/commands/run.py +224 -0
- minizen-0.1.0/src/minizen/cli/commands/setup.py +246 -0
- minizen-0.1.0/src/minizen/cli/state.py +15 -0
- minizen-0.1.0/src/minizen/config/__init__.py +6 -0
- minizen-0.1.0/src/minizen/config/loader.py +49 -0
- minizen-0.1.0/src/minizen/config/models.py +43 -0
- minizen-0.1.0/src/minizen/core/__init__.py +5 -0
- minizen-0.1.0/src/minizen/core/pipeline.py +45 -0
- minizen-0.1.0/src/minizen/main.py +4 -0
- minizen-0.1.0/src/minizen/providers/__init__.py +0 -0
- minizen-0.1.0/src/minizen/providers/email/__init__.py +5 -0
- minizen-0.1.0/src/minizen/providers/email/smtp.py +50 -0
- minizen-0.1.0/src/minizen/providers/email/template.py +187 -0
- minizen-0.1.0/src/minizen/providers/rss/__init__.py +5 -0
- minizen-0.1.0/src/minizen/providers/rss/miniflux.py +67 -0
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
|
+
```
|
minizen-0.1.0/README.md
ADDED
|
@@ -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,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.")
|