use-agent 1.0.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.
Files changed (39) hide show
  1. use_agent-1.0.0/.github/workflows/deploy.yaml +45 -0
  2. use_agent-1.0.0/.github/workflows/testing.yaml +36 -0
  3. use_agent-1.0.0/.gitignore +22 -0
  4. use_agent-1.0.0/.pre-commit-config.yaml +23 -0
  5. use_agent-1.0.0/CLAUDE.md +221 -0
  6. use_agent-1.0.0/LICENSE +25 -0
  7. use_agent-1.0.0/PKG-INFO +396 -0
  8. use_agent-1.0.0/README.md +369 -0
  9. use_agent-1.0.0/config.example.toml +129 -0
  10. use_agent-1.0.0/pyproject.toml +103 -0
  11. use_agent-1.0.0/tests/__init__.py +0 -0
  12. use_agent-1.0.0/tests/test_cache.py +100 -0
  13. use_agent-1.0.0/tests/test_cli.py +74 -0
  14. use_agent-1.0.0/tests/test_config.py +93 -0
  15. use_agent-1.0.0/tests/test_gmail_headers.py +141 -0
  16. use_agent-1.0.0/tests/test_prompt_rendering.py +218 -0
  17. use_agent-1.0.0/tests/test_prune_scope.py +39 -0
  18. use_agent-1.0.0/tests/test_reconcile.py +96 -0
  19. use_agent-1.0.0/tests/test_report.py +173 -0
  20. use_agent-1.0.0/tests/test_reporter.py +116 -0
  21. use_agent-1.0.0/tests/test_settings.py +303 -0
  22. use_agent-1.0.0/tests/test_storage.py +159 -0
  23. use_agent-1.0.0/use_agent/__init__.py +8 -0
  24. use_agent-1.0.0/use_agent/__main__.py +4 -0
  25. use_agent-1.0.0/use_agent/agent.py +523 -0
  26. use_agent-1.0.0/use_agent/auth.py +60 -0
  27. use_agent-1.0.0/use_agent/cache.py +81 -0
  28. use_agent-1.0.0/use_agent/cli.py +311 -0
  29. use_agent-1.0.0/use_agent/config.py +70 -0
  30. use_agent-1.0.0/use_agent/gmail.py +500 -0
  31. use_agent-1.0.0/use_agent/prompts/classifier.md +215 -0
  32. use_agent-1.0.0/use_agent/prompts/reply.md +95 -0
  33. use_agent-1.0.0/use_agent/report.py +309 -0
  34. use_agent-1.0.0/use_agent/reporter.py +201 -0
  35. use_agent-1.0.0/use_agent/settings.py +207 -0
  36. use_agent-1.0.0/use_agent/storage.py +179 -0
  37. use_agent-1.0.0/use_agent/templates/report.html.j2 +138 -0
  38. use_agent-1.0.0/use_agent/tools.py +370 -0
  39. use_agent-1.0.0/uv.lock +1033 -0
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+
6
+ permissions:
7
+ id-token: write
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v6
15
+ with:
16
+ fetch-depth: 0
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+
21
+ - name: Set up Python
22
+ run: uv python install 3.14
23
+
24
+ - name: Build
25
+ run: uv build
26
+
27
+ - name: Upload dist
28
+ uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ publish:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ steps:
38
+ - name: Download dist
39
+ uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+
44
+ - name: Publish package
45
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,36 @@
1
+ name: Testing
2
+ on:
3
+ push:
4
+ branches: ["**"]
5
+ paths-ignore:
6
+ - 'docs/**'
7
+ - '*.md'
8
+ tags-ignore: ["*"]
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python: ["3.14"]
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v6
18
+ with:
19
+ fetch-depth: 0
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v5
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+
26
+ - name: Install dependencies
27
+ run: uv sync
28
+
29
+ - name: Run ruff check
30
+ run: uv run ruff check .
31
+
32
+ - name: Run ruff format check
33
+ run: uv run ruff format --check .
34
+
35
+ - name: Run tests
36
+ run: uv run coverage run && uv run coverage report
@@ -0,0 +1,22 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ build/
6
+ dist/
7
+ .coverage
8
+ .coverage.*
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+
12
+ # Local secrets & private config — NEVER commit
13
+ credentials.json
14
+ token.json
15
+ config.toml
16
+ *.pem
17
+ .env
18
+ .env.*
19
+ !.env.example
20
+ !config.example.toml
21
+
22
+ justfile
@@ -0,0 +1,23 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-added-large-files
9
+ - id: check-merge-conflict
10
+ - id: check-toml
11
+ - id: debug-statements
12
+
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: v0.15.11
15
+ hooks:
16
+ - id: ruff-check
17
+ args: [--fix]
18
+ - id: ruff-format
19
+
20
+ - repo: https://github.com/tombi-toml/tombi-pre-commit
21
+ rev: v0.7.25
22
+ hooks:
23
+ - id: tombi-format
@@ -0,0 +1,221 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ `use-agent` is a Claude Agent that triages a Gmail inbox for
8
+ unsolicited sales email and bulk marketing. It replies to cold
9
+ sales pitches in the user's voice and archives them; for bulk
10
+ marketing it honors the RFC 8058 one-click unsubscribe and
11
+ Trashes the thread. It's built on the Claude Agent SDK; Gmail API
12
+ operations are exposed as tools via an in-process SDK MCP server.
13
+
14
+ Python 3.14, uv, hatchling + hatch-vcs, ruff. Runtime deps:
15
+ `claude-agent-sdk`, `google-api-python-client`,
16
+ `google-auth-oauthlib`, `jinja2`, `rich`.
17
+
18
+ ## Commands
19
+
20
+ ```bash
21
+ uv sync # install runtime + dev deps
22
+ uv run use-agent auth # one-time Gmail OAuth
23
+ uv run use-agent run --dry-run # classify only
24
+ uv run use-agent run # full run: reply + archive
25
+ uv run use-agent run --daemon --interval 30m --logfile run.log
26
+ uv run use-agent run --json | jq . # stdout is pure JSON
27
+
28
+ uv run ruff check .
29
+ uv run ruff format --check .
30
+ uv run coverage run # pytest under coverage
31
+ uv run coverage report
32
+ ```
33
+
34
+ `ANTHROPIC_API_KEY` is required for any `run`.
35
+
36
+ ## Architecture
37
+
38
+ The data flow is deliberately narrow. All user-specific context enters
39
+ through `config.toml`; everything else is derivable from the code.
40
+
41
+ ```
42
+ cli.main → Settings.load() → agent.run(settings, reporter)
43
+
44
+ _render_system_prompt(settings)
45
+
46
+ claude_agent_sdk.query(
47
+ prompt=<user-prompt>,
48
+ options=ClaudeAgentOptions(
49
+ system_prompt=<rendered>,
50
+ mcp_servers={'gmail': <built from GmailClient>},
51
+ allowed_tools=[mcp__gmail__search, ...],
52
+ setting_sources=[], # hermetic
53
+ model=<from config>,
54
+ ),
55
+ )
56
+
57
+ streaming AssistantMessage/TextBlock
58
+
59
+ reporter.on_text(...) (→ narration logger)
60
+
61
+ reporter.finish() (→ stdout: table/JSON)
62
+ ```
63
+
64
+ Key module responsibilities:
65
+
66
+ - `gmail.GmailClient` is the only module that touches the Gmail REST
67
+ API. It returns frozen `Message` dataclasses so tool output stays
68
+ JSON-friendly.
69
+ - `tools.build_mcp_server(client)` wraps six `@claude_agent_sdk.tool`
70
+ closures (`search`, `get_message`, `reply`, `archive_and_mark_read`,
71
+ `unsubscribe_and_trash`, `trash`) over a `GmailClient` and returns
72
+ an SDK MCP server. The closure approach lets the tools share state
73
+ without a module-global client.
74
+ - The `unsubscribe_and_trash` tool takes the `list_unsubscribe` and
75
+ `list_unsubscribe_post` header strings as arguments (not just the
76
+ message_id) so it doesn't re-fetch the message. Headers come from
77
+ the preceding `get_message` call. The tool chooses between RFC 8058
78
+ one-click HTTPS POST, HTTPS GET, and mailto based on what the
79
+ headers expose (see `tools._pick_unsubscribe_action`).
80
+ - `agent.run()` is the orchestration entry point: renders the system
81
+ prompt, builds the tool server, configures `ClaudeAgentOptions`,
82
+ streams responses into the `Reporter`, and returns an exit code.
83
+ Python does no per-message logic — the agent drives the loop.
84
+ - `reporter.Reporter` owns all output. **`on_text` never writes to
85
+ stdout**; narration goes to the `use_agent.narration` logger
86
+ instead. `finish()` parses the last ` ```json ` fenced block from
87
+ the buffered text and renders it as a Rich table (`pretty`),
88
+ pipe-delimited ASCII (`plain`), or a JSON document (`json`).
89
+ - `settings.Settings` is a frozen dataclass loaded from
90
+ `config.toml` (path via `USE_AGENT_CONFIG` env var or
91
+ `~/.config/use-agent/config.toml`). If `[search] query` is omitted,
92
+ a default `in:inbox is:unread` query is built from the safelist
93
+ domains as `-from:<domain>` filters.
94
+
95
+ ### Prompts are Jinja2 templates
96
+
97
+ `use_agent/prompts/classifier.md` and `reply.md` are Jinja2 templates
98
+ rendered at startup. Context injected by `agent._render_context`:
99
+ `user_name`, `organization`, `safelist_domains`, `vendor_names`,
100
+ `voice_guidelines`, `newsletter_keep_domains`,
101
+ `newsletter_keep_list_ids`, `reply_footer`, plus pre-rendered strings
102
+ (`voice_block`, `footer_block`, `footer_instruction`,
103
+ `hard_remove_examples`, etc.). `newsletter_keep_block` is rendered
104
+ separately in `_render_system_prompt` because only the top-level
105
+ system prompt template uses it. The pre-rendered strings exist
106
+ because the repo's pre-commit Markdown formatter strips blank lines
107
+ inside `{% if %}` blocks — avoid Jinja control flow in prompt
108
+ Markdown; pre-render composite blocks in Python and inject them as
109
+ single `{{ ... }}` substitutions.
110
+
111
+ `reply_footer` is itself a Jinja string (may reference `{{ user_name
112
+ }}` etc.) and is rendered once in `_render_context` before being
113
+ injected into the reply template.
114
+
115
+ ### Editing agent behavior without touching Python
116
+
117
+ Most behavior tweaks belong in `config.toml` or the prompt Markdown:
118
+
119
+ - New classification signal → edit `prompts/classifier.md`
120
+ - Change reply voice / add a template → edit `prompts/reply.md`
121
+ - Add a vendor exemption → `[vendors] names` in `config.toml`
122
+ - Add a safelisted domain → `[safelist] domains` in `config.toml`
123
+ - Keep a bulk sender (community list, opted-in newsletter) →
124
+ `[newsletters] keep_domains` / `keep_list_ids` in `config.toml`
125
+ - Change footer text / disable footer → `[voice] footer` (or `""`)
126
+ - Change model → `[agent] model`
127
+
128
+ Python changes are only needed when the set of tools, output modes,
129
+ CLI flags, or Gmail operations needs to change.
130
+
131
+ ### Hermetic SDK configuration
132
+
133
+ `agent.run()` passes `setting_sources=[]`, `settings=None`,
134
+ `skills=None` to `ClaudeAgentOptions` so the agent never merges in
135
+ the developer's `~/.claude/settings.json`, project-level Claude Code
136
+ config, plugins, or auto-discovered skills. The only MCP server the
137
+ agent sees is our Gmail one; the only tools it can call are the four
138
+ in `tools.ALLOWED_TOOLS`. Keep it that way — preserving isolation is
139
+ part of the trust model.
140
+
141
+ ### Threaded replies
142
+
143
+ Gmail only collapses a reply into the original conversation when all
144
+ three are correct: `In-Reply-To: <original Message-ID>`,
145
+ `References: <chain> <original Message-ID>`, and `threadId` in the
146
+ `users.messages.send` request. `gmail.GmailClient.reply` assembles
147
+ all three from the fetched original. Don't regress this — dropping
148
+ `threadId` or the headers silently demotes replies into new threads.
149
+
150
+ ### Unsubscribing (RFC 2369 / RFC 8058)
151
+
152
+ `gmail.unsubscribe_targets(list_unsubscribe, list_unsubscribe_post)`
153
+ parses the raw `List-Unsubscribe` header into `{http_urls, mailtos,
154
+ one_click}`. The splitter (`_split_list_unsubscribe`) respects
155
+ angle-bracket nesting because mailto URIs may contain literal
156
+ commas in a `body=` parameter — a plain `split(',')` breaks those.
157
+
158
+ The `unsubscribe_and_trash` tool calls `_pick_unsubscribe_action`,
159
+ which returns `(method, target)`:
160
+
161
+ 1. `one_click_post` — RFC 8058: POST `List-Unsubscribe=One-Click`
162
+ as a form body to the HTTPS URI. This is what Gmail and Apple
163
+ Mail use when the user clicks the native "Unsubscribe" button.
164
+ 2. `http_get` — GET the HTTPS URI.
165
+ 3. `mailto` — send a standalone (non-threaded) email via
166
+ `GmailClient.send_unsubscribe_mail` to the address in the
167
+ mailto URI, using its `subject=` / `body=` parameters.
168
+ 4. `none` — no endpoint available; Trash only.
169
+
170
+ Both the HTTP request (`_http_unsubscribe`) and the mailto send are
171
+ sync-blocking. They run inside the async MCP tool handler, which
172
+ matches the existing googleapiclient pattern throughout `tools.py`.
173
+ The HTTP request has a 10-second timeout (`_UNSUB_HTTP_TIMEOUT`) so
174
+ a stalled endpoint can't hang the whole run.
175
+
176
+ In-body HTML unsubscribe links are NEVER clicked — clicking
177
+ validates the address to shady senders who already ignored the
178
+ RFC. Messages with no `List-Unsubscribe` header get
179
+ `response_mode=delete` and go straight to Trash.
180
+
181
+ ### Output contract
182
+
183
+ The agent is prompted to emit exactly one fenced ` ```json ` block
184
+ containing `{"results": [...]}`. The reporter's `_extract_summary`
185
+ walks the buffered text from the end backwards looking for the last
186
+ parseable JSON fence with a `results` array (or a bare list of rows
187
+ with a `classification` key). If the agent forgets to emit the
188
+ block, `finish()` returns exit code 1 — treat this as a real error.
189
+
190
+ ### Logging
191
+
192
+ | Logger | Level | Purpose |
193
+ |---|---|---|
194
+ | `use_agent.*` | INFO | Notable one-offs (OAuth flow, reply sent); lifecycle (run start, daemon tick, cache stats) is DEBUG |
195
+ | `use_agent.narration` | INFO | Agent's running commentary (buffered text) |
196
+ | `use_agent.tools` | DEBUG | Per-tool-call Gmail operations (search, get, reply, archive, unsubscribe, trash) |
197
+ | `claude_agent_sdk` | WARNING | Pinned; SDK INFO is too chatty |
198
+
199
+ All handlers write to stderr (plus the `--logfile` target when set)
200
+ so stdout stays pure in `--json` mode.
201
+
202
+ ## Conventions
203
+
204
+ - **Module-level imports only.** A pre-commit hook rejects `from
205
+ pathlib import Path` / `from typing import Any` style imports —
206
+ use `import pathlib` / `import typing`, then `pathlib.Path(...)`,
207
+ `typing.Any`.
208
+ - Ruff: 79-char lines, single quotes, `py314` target. Config lives
209
+ in `pyproject.toml`.
210
+ - No compound shell commands: each `Bash` call is one command
211
+ (repo-wide preference, not project-specific, but still applies).
212
+ - `credentials.json`, `token.json`, and `config.toml` are all
213
+ gitignored. The repo itself contains no identifying information;
214
+ `config.example.toml` uses placeholder values only.
215
+
216
+ ## Version
217
+
218
+ `use_agent.__version__` is read at runtime via
219
+ `importlib.metadata.version('use-agent')`, with a `0.0.0+unknown`
220
+ fallback. `[project] version` is pinned in `pyproject.toml`; hatch-vcs
221
+ is configured but not currently generating a version file.
@@ -0,0 +1,25 @@
1
+ Copyright (c) 2026 Gavin M. Roy, AWeber
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification,
5
+ are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+ * Neither the name of the copyright holder nor the names of its contributors
13
+ may be used to endorse or promote products derived from this software without
14
+ specific prior written permission.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
17
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
20
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21
+ BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
23
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
24
+ OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
25
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.