use-agent 1.0.0b1__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.
- use_agent/__init__.py +8 -0
- use_agent/__main__.py +4 -0
- use_agent/agent.py +281 -0
- use_agent/auth.py +60 -0
- use_agent/cli.py +202 -0
- use_agent/config.py +44 -0
- use_agent/gmail.py +246 -0
- use_agent/prompts/classifier.md +95 -0
- use_agent/prompts/reply.md +95 -0
- use_agent/reporter.py +186 -0
- use_agent/settings.py +161 -0
- use_agent/tools.py +106 -0
- use_agent-1.0.0b1.dist-info/METADATA +283 -0
- use_agent-1.0.0b1.dist-info/RECORD +16 -0
- use_agent-1.0.0b1.dist-info/WHEEL +4 -0
- use_agent-1.0.0b1.dist-info/entry_points.txt +2 -0
use_agent/__init__.py
ADDED
use_agent/__main__.py
ADDED
use_agent/agent.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Orchestrates a Claude Agent run over the Gmail inbox."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import claude_agent_sdk
|
|
6
|
+
import jinja2
|
|
7
|
+
|
|
8
|
+
from use_agent import (
|
|
9
|
+
auth,
|
|
10
|
+
config,
|
|
11
|
+
gmail,
|
|
12
|
+
tools,
|
|
13
|
+
)
|
|
14
|
+
from use_agent import (
|
|
15
|
+
reporter as reporter_mod,
|
|
16
|
+
)
|
|
17
|
+
from use_agent import (
|
|
18
|
+
settings as settings_mod,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SYSTEM_PROMPT_TEMPLATE = """\
|
|
25
|
+
You are use-agent, a triage assistant for {{ user_name }}'s Gmail
|
|
26
|
+
inbox. Your job is to find unsolicited sales email ("cold sales"),
|
|
27
|
+
reply to it on {{ user_name }}'s behalf in their voice, and then
|
|
28
|
+
archive the thread.
|
|
29
|
+
|
|
30
|
+
You have the following tools (prefixed with ``mcp__gmail__``):
|
|
31
|
+
- ``search``: run a Gmail search query, returns message ids
|
|
32
|
+
- ``get_message``: fetch full message (headers, body, thread state)
|
|
33
|
+
- ``reply``: send a threaded reply to a message
|
|
34
|
+
- ``archive_and_mark_read``: archive the thread and mark it read
|
|
35
|
+
|
|
36
|
+
## Workflow
|
|
37
|
+
|
|
38
|
+
1. Call ``search`` with the query provided in the user message.
|
|
39
|
+
2. For each returned message, call ``get_message``.
|
|
40
|
+
3. Skip any message where ``thread_replied`` is true, where the
|
|
41
|
+
sender domain is in the safelist below, or where the classifier
|
|
42
|
+
result is not COLD_SALES.
|
|
43
|
+
4. For each COLD_SALES message, generate a reply body using the
|
|
44
|
+
reply rules below, then call ``reply`` with that body. On
|
|
45
|
+
success, call ``archive_and_mark_read``.
|
|
46
|
+
5. If ``dry_run`` is true, do everything except call ``reply`` and
|
|
47
|
+
``archive_and_mark_read``. Still report what you would have done.
|
|
48
|
+
6. When finished, emit a single fenced JSON block (no other JSON
|
|
49
|
+
in your output) with a top-level ``results`` array. One entry
|
|
50
|
+
per examined message; each entry has these keys:
|
|
51
|
+
|
|
52
|
+
- ``sender``: the original ``From`` header, including name
|
|
53
|
+
- ``subject``: the original subject
|
|
54
|
+
- ``classification``: ``COLD_SALES`` or ``NOT_COLD_SALES``
|
|
55
|
+
- ``score``: integer
|
|
56
|
+
- ``response_mode``: ``hard_remove``,
|
|
57
|
+
``hard_remove_with_correction``, ``specific_decline``, or
|
|
58
|
+
``none``
|
|
59
|
+
- ``action_taken``: one of ``Reply sent & archived``,
|
|
60
|
+
``Dry-run: would reply & archive``,
|
|
61
|
+
``Skipped (not cold sales)``, ``Skipped (already replied)``,
|
|
62
|
+
or ``Error: <detail>``
|
|
63
|
+
|
|
64
|
+
Use a ```` ```json ```` fence. The block is parsed by the host
|
|
65
|
+
program, so it must be valid JSON.
|
|
66
|
+
|
|
67
|
+
Never invent or fabricate email content. Never send anything other
|
|
68
|
+
than the reply text produced by the reply rules. If a tool fails,
|
|
69
|
+
record the error in the corresponding ``action_taken`` field and
|
|
70
|
+
continue with the next message.
|
|
71
|
+
|
|
72
|
+
## Safelist domains
|
|
73
|
+
|
|
74
|
+
{% if safelist_domains %}
|
|
75
|
+
Treat senders from any of these domains as internal. Never classify
|
|
76
|
+
them as cold sales and never reply:
|
|
77
|
+
|
|
78
|
+
{% for d in safelist_domains %}
|
|
79
|
+
- {{ d }}
|
|
80
|
+
{% endfor %}
|
|
81
|
+
{% else %}
|
|
82
|
+
(none configured)
|
|
83
|
+
{% endif %}
|
|
84
|
+
|
|
85
|
+
## Classification rules
|
|
86
|
+
|
|
87
|
+
{{ classifier }}
|
|
88
|
+
|
|
89
|
+
## Reply rules
|
|
90
|
+
|
|
91
|
+
{{ reply }}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _jinja_env() -> jinja2.Environment:
|
|
96
|
+
return jinja2.Environment(
|
|
97
|
+
loader=jinja2.FileSystemLoader(str(config.PROMPTS_DIR)),
|
|
98
|
+
autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
|
|
99
|
+
keep_trailing_newline=True,
|
|
100
|
+
undefined=jinja2.StrictUndefined,
|
|
101
|
+
trim_blocks=True,
|
|
102
|
+
lstrip_blocks=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_template(
|
|
107
|
+
env: jinja2.Environment,
|
|
108
|
+
name: str,
|
|
109
|
+
settings: settings_mod.Settings,
|
|
110
|
+
) -> str:
|
|
111
|
+
template = env.get_template(name)
|
|
112
|
+
return template.render(_render_context(env, settings))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_context(
|
|
116
|
+
env: jinja2.Environment,
|
|
117
|
+
settings: settings_mod.Settings,
|
|
118
|
+
) -> dict[str, object]:
|
|
119
|
+
base = {
|
|
120
|
+
'user_name': settings.user_name,
|
|
121
|
+
'organization': settings.organization,
|
|
122
|
+
'safelist_domains': list(settings.safelist_domains),
|
|
123
|
+
'vendor_names': list(settings.vendor_names),
|
|
124
|
+
'voice_guidelines': list(settings.voice_guidelines),
|
|
125
|
+
}
|
|
126
|
+
# The footer may itself be a Jinja string (e.g. "{{ user_name }}").
|
|
127
|
+
rendered_footer = (
|
|
128
|
+
env.from_string(settings.reply_footer).render(base)
|
|
129
|
+
if settings.reply_footer
|
|
130
|
+
else ''
|
|
131
|
+
)
|
|
132
|
+
voice_block = '\n'.join(f'- {g}' for g in settings.voice_guidelines)
|
|
133
|
+
# Example bullets may themselves contain Jinja refs like
|
|
134
|
+
# {{ organization }}; render each one against the base context
|
|
135
|
+
# before joining so interpolation happens exactly once.
|
|
136
|
+
hard_remove_examples = _render_examples(
|
|
137
|
+
env, settings.examples_hard_remove, base
|
|
138
|
+
)
|
|
139
|
+
hard_remove_with_correction_examples = _render_examples(
|
|
140
|
+
env, settings.examples_hard_remove_with_correction, base
|
|
141
|
+
)
|
|
142
|
+
specific_decline_examples = _render_examples(
|
|
143
|
+
env, settings.examples_specific_decline, base
|
|
144
|
+
)
|
|
145
|
+
# Pre-rendered blocks are inserted verbatim so prompt markdown
|
|
146
|
+
# stays free of Jinja control flow (formatters don't preserve
|
|
147
|
+
# blank lines inside {% if %} blocks).
|
|
148
|
+
if rendered_footer:
|
|
149
|
+
footer_block = f'\n\n{rendered_footer}'
|
|
150
|
+
footer_instruction = (
|
|
151
|
+
'Every reply must end with the following footer, '
|
|
152
|
+
'appended after the reply body on a new line, '
|
|
153
|
+
'separated by a blank line:\n\n'
|
|
154
|
+
f'```\n{rendered_footer}\n```'
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
footer_block = ''
|
|
158
|
+
footer_instruction = (
|
|
159
|
+
'No footer — send the reply body verbatim with no trailing text.'
|
|
160
|
+
)
|
|
161
|
+
return {
|
|
162
|
+
**base,
|
|
163
|
+
'reply_footer': rendered_footer,
|
|
164
|
+
'voice_block': voice_block,
|
|
165
|
+
'footer_block': footer_block,
|
|
166
|
+
'footer_instruction': footer_instruction,
|
|
167
|
+
'hard_remove_examples': hard_remove_examples,
|
|
168
|
+
'hard_remove_with_correction_examples': (
|
|
169
|
+
hard_remove_with_correction_examples
|
|
170
|
+
),
|
|
171
|
+
'specific_decline_examples': specific_decline_examples,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _render_examples(
|
|
176
|
+
env: jinja2.Environment,
|
|
177
|
+
items: tuple[str, ...],
|
|
178
|
+
context: dict[str, object],
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Render each example as a Markdown bullet.
|
|
181
|
+
|
|
182
|
+
An empty list collapses to the single line "(none configured)"
|
|
183
|
+
so the rendered prompt always has something under the heading.
|
|
184
|
+
"""
|
|
185
|
+
if not items:
|
|
186
|
+
return '(none configured)'
|
|
187
|
+
bullets: list[str] = []
|
|
188
|
+
for item in items:
|
|
189
|
+
rendered = env.from_string(item).render(context).strip()
|
|
190
|
+
bullets.append(f'- "{rendered}"')
|
|
191
|
+
return '\n'.join(bullets)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render_system_prompt(settings: settings_mod.Settings) -> str:
|
|
195
|
+
env = _jinja_env()
|
|
196
|
+
classifier = _render_template(env, config.CLASSIFIER_PROMPT.name, settings)
|
|
197
|
+
reply = _render_template(env, config.REPLY_PROMPT.name, settings)
|
|
198
|
+
system_template = jinja2.Environment(
|
|
199
|
+
autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
|
|
200
|
+
keep_trailing_newline=True,
|
|
201
|
+
undefined=jinja2.StrictUndefined,
|
|
202
|
+
trim_blocks=True,
|
|
203
|
+
lstrip_blocks=True,
|
|
204
|
+
).from_string(SYSTEM_PROMPT_TEMPLATE)
|
|
205
|
+
return system_template.render(
|
|
206
|
+
user_name=settings.user_name,
|
|
207
|
+
safelist_domains=list(settings.safelist_domains),
|
|
208
|
+
classifier=classifier.strip(),
|
|
209
|
+
reply=reply.strip(),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _user_prompt(*, query: str, max_results: int, dry_run: bool) -> str:
|
|
214
|
+
return (
|
|
215
|
+
'Process the Gmail inbox now.\n\n'
|
|
216
|
+
f'search query: {query}\n'
|
|
217
|
+
f'max_results: {max_results}\n'
|
|
218
|
+
f'dry_run: {str(dry_run).lower()}\n'
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def run(
|
|
223
|
+
*,
|
|
224
|
+
settings: settings_mod.Settings,
|
|
225
|
+
reporter: reporter_mod.Reporter,
|
|
226
|
+
query: str | None = None,
|
|
227
|
+
max_results: int | None = None,
|
|
228
|
+
dry_run: bool = False,
|
|
229
|
+
) -> int:
|
|
230
|
+
"""Execute a single agent pass over the inbox.
|
|
231
|
+
|
|
232
|
+
Returns a process exit code — 0 on a successful run with a
|
|
233
|
+
parsed summary, non-zero if the agent produced no summary.
|
|
234
|
+
"""
|
|
235
|
+
effective_query = query or settings.search_query
|
|
236
|
+
effective_max = max_results or settings.max_results
|
|
237
|
+
creds = auth.load_credentials(
|
|
238
|
+
credentials_file=config.credentials_path(),
|
|
239
|
+
token_file=config.token_path(),
|
|
240
|
+
scopes=config.GMAIL_SCOPES,
|
|
241
|
+
)
|
|
242
|
+
client = gmail.GmailClient(creds)
|
|
243
|
+
server = tools.build_mcp_server(client)
|
|
244
|
+
options = claude_agent_sdk.ClaudeAgentOptions(
|
|
245
|
+
system_prompt=_render_system_prompt(settings),
|
|
246
|
+
mcp_servers={tools.MCP_SERVER_NAME: server},
|
|
247
|
+
allowed_tools=list(tools.ALLOWED_TOOLS),
|
|
248
|
+
permission_mode='acceptEdits',
|
|
249
|
+
model=settings.model,
|
|
250
|
+
# Isolate from the host's Claude Code setup: no user/project
|
|
251
|
+
# settings, no on-disk settings file, no auto-loaded skills.
|
|
252
|
+
# Only our Gmail MCP server and system prompt drive behavior.
|
|
253
|
+
setting_sources=[],
|
|
254
|
+
settings=None,
|
|
255
|
+
skills=None,
|
|
256
|
+
)
|
|
257
|
+
prompt = _user_prompt(
|
|
258
|
+
query=effective_query,
|
|
259
|
+
max_results=effective_max,
|
|
260
|
+
dry_run=dry_run,
|
|
261
|
+
)
|
|
262
|
+
LOGGER.info(
|
|
263
|
+
'starting agent run: query=%r max=%d dry_run=%s model=%s',
|
|
264
|
+
effective_query,
|
|
265
|
+
effective_max,
|
|
266
|
+
dry_run,
|
|
267
|
+
settings.model,
|
|
268
|
+
)
|
|
269
|
+
async for message in claude_agent_sdk.query(
|
|
270
|
+
prompt=prompt, options=options
|
|
271
|
+
):
|
|
272
|
+
_forward(message, reporter)
|
|
273
|
+
return reporter.finish()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _forward(message: object, reporter: reporter_mod.Reporter) -> None:
|
|
277
|
+
"""Feed assistant text blocks to the reporter."""
|
|
278
|
+
if isinstance(message, claude_agent_sdk.AssistantMessage):
|
|
279
|
+
for block in message.content:
|
|
280
|
+
if isinstance(block, claude_agent_sdk.TextBlock):
|
|
281
|
+
reporter.on_text(block.text)
|
use_agent/auth.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Google OAuth installed-app flow for Gmail."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
import google.auth.transport.requests
|
|
7
|
+
import google.oauth2.credentials
|
|
8
|
+
import google_auth_oauthlib.flow
|
|
9
|
+
|
|
10
|
+
LOGGER = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_credentials(
|
|
14
|
+
*,
|
|
15
|
+
credentials_file: pathlib.Path,
|
|
16
|
+
token_file: pathlib.Path,
|
|
17
|
+
scopes: tuple[str, ...],
|
|
18
|
+
) -> google.oauth2.credentials.Credentials:
|
|
19
|
+
"""Return valid Credentials, running the OAuth flow if needed.
|
|
20
|
+
|
|
21
|
+
If a token file exists, load and refresh it. Otherwise, run the
|
|
22
|
+
installed-app browser flow using ``credentials_file`` and persist
|
|
23
|
+
the resulting token.
|
|
24
|
+
"""
|
|
25
|
+
creds: google.oauth2.credentials.Credentials | None = None
|
|
26
|
+
if token_file.exists():
|
|
27
|
+
creds = (
|
|
28
|
+
google.oauth2.credentials.Credentials.from_authorized_user_file(
|
|
29
|
+
str(token_file), list(scopes)
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
if creds and creds.valid:
|
|
33
|
+
return creds
|
|
34
|
+
if creds and creds.expired and creds.refresh_token:
|
|
35
|
+
LOGGER.info('refreshing Gmail OAuth token')
|
|
36
|
+
creds.refresh(google.auth.transport.requests.Request())
|
|
37
|
+
_write_token(token_file, creds)
|
|
38
|
+
return creds
|
|
39
|
+
if not credentials_file.exists():
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
f'OAuth client secret not found at {credentials_file}. '
|
|
42
|
+
'Create one in Google Cloud Console (Desktop app) and '
|
|
43
|
+
'place it there, or set USE_AGENT_CREDENTIALS.'
|
|
44
|
+
)
|
|
45
|
+
LOGGER.info('running OAuth installed-app flow')
|
|
46
|
+
flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
|
|
47
|
+
str(credentials_file), list(scopes)
|
|
48
|
+
)
|
|
49
|
+
creds = flow.run_local_server(port=0)
|
|
50
|
+
_write_token(token_file, creds)
|
|
51
|
+
return creds
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_token(
|
|
55
|
+
path: pathlib.Path,
|
|
56
|
+
creds: google.oauth2.credentials.Credentials,
|
|
57
|
+
) -> None:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
path.write_text(creds.to_json())
|
|
60
|
+
path.chmod(0o600)
|
use_agent/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command-line entry point for use-agent."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from use_agent import agent, auth, config, reporter
|
|
11
|
+
from use_agent import settings as settings_mod
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_INTERVAL_RE = re.compile(r'^(?P<n>\d+)(?P<unit>[smhd]?)$')
|
|
16
|
+
_UNIT_SECONDS = {'': 1, 's': 1, 'm': 60, 'h': 3600, 'd': 86400}
|
|
17
|
+
DEFAULT_INTERVAL_SECONDS = 15 * 60
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_interval(raw: str) -> int:
|
|
21
|
+
match = _INTERVAL_RE.fullmatch(raw.strip().lower())
|
|
22
|
+
if not match:
|
|
23
|
+
raise argparse.ArgumentTypeError(
|
|
24
|
+
f'invalid interval {raw!r}; use e.g. 30s, 15m, 2h, 1d'
|
|
25
|
+
)
|
|
26
|
+
seconds = int(match.group('n')) * _UNIT_SECONDS[match.group('unit')]
|
|
27
|
+
if seconds <= 0:
|
|
28
|
+
raise argparse.ArgumentTypeError(
|
|
29
|
+
f'interval must be positive, got {raw!r}'
|
|
30
|
+
)
|
|
31
|
+
return seconds
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog='use-agent',
|
|
37
|
+
description=('Claude Agent that triages cold sales email in Gmail.'),
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
'-v',
|
|
41
|
+
'--verbose',
|
|
42
|
+
action='store_true',
|
|
43
|
+
help='enable debug logging',
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
'--logfile',
|
|
47
|
+
type=pathlib.Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help='append structured logs to this file (in addition to stderr)',
|
|
50
|
+
)
|
|
51
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
52
|
+
|
|
53
|
+
sub.add_parser(
|
|
54
|
+
'auth',
|
|
55
|
+
help='run the Gmail OAuth flow and save credentials',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
run_p = sub.add_parser('run', help='process the inbox once')
|
|
59
|
+
run_p.add_argument(
|
|
60
|
+
'--query',
|
|
61
|
+
default=None,
|
|
62
|
+
help=(
|
|
63
|
+
'override the Gmail search query; defaults to the one '
|
|
64
|
+
'in config.toml (or one built from the safelist)'
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
run_p.add_argument(
|
|
68
|
+
'--max',
|
|
69
|
+
dest='max_results',
|
|
70
|
+
type=int,
|
|
71
|
+
default=None,
|
|
72
|
+
help='override maximum candidates to examine',
|
|
73
|
+
)
|
|
74
|
+
run_p.add_argument(
|
|
75
|
+
'--dry-run',
|
|
76
|
+
action='store_true',
|
|
77
|
+
help='classify only; do not reply or archive',
|
|
78
|
+
)
|
|
79
|
+
run_p.add_argument(
|
|
80
|
+
'--daemon',
|
|
81
|
+
action='store_true',
|
|
82
|
+
help='run continuously; re-process the inbox every --interval',
|
|
83
|
+
)
|
|
84
|
+
run_p.add_argument(
|
|
85
|
+
'--interval',
|
|
86
|
+
type=_parse_interval,
|
|
87
|
+
default=DEFAULT_INTERVAL_SECONDS,
|
|
88
|
+
help='daemon interval (e.g. 30s, 15m, 2h); default 15m',
|
|
89
|
+
)
|
|
90
|
+
out = run_p.add_mutually_exclusive_group()
|
|
91
|
+
out.add_argument(
|
|
92
|
+
'--plain',
|
|
93
|
+
dest='output',
|
|
94
|
+
action='store_const',
|
|
95
|
+
const=reporter.Mode.PLAIN,
|
|
96
|
+
help='disable ANSI colours and render a pipe-delimited table',
|
|
97
|
+
)
|
|
98
|
+
out.add_argument(
|
|
99
|
+
'--json',
|
|
100
|
+
dest='output',
|
|
101
|
+
action='store_const',
|
|
102
|
+
const=reporter.Mode.JSON,
|
|
103
|
+
help='emit only the parsed summary as JSON on stdout',
|
|
104
|
+
)
|
|
105
|
+
run_p.set_defaults(output=reporter.Mode.PRETTY)
|
|
106
|
+
return parser
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _configure_logging(*, verbose: bool, logfile: pathlib.Path | None) -> None:
|
|
110
|
+
# Log to stderr so --json stdout stays parseable.
|
|
111
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
112
|
+
fmt = '%(asctime)s %(levelname)s %(name)s: %(message)s'
|
|
113
|
+
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
|
114
|
+
if logfile is not None:
|
|
115
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
handlers.append(logging.FileHandler(logfile, encoding='utf-8'))
|
|
117
|
+
logging.basicConfig(
|
|
118
|
+
level=level,
|
|
119
|
+
format=fmt,
|
|
120
|
+
handlers=handlers,
|
|
121
|
+
force=True,
|
|
122
|
+
)
|
|
123
|
+
# SDK's own INFO stream is too chatty for our usage.
|
|
124
|
+
logging.getLogger('claude_agent_sdk').setLevel(logging.WARNING)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cmd_auth() -> int:
|
|
128
|
+
creds_file = config.credentials_path()
|
|
129
|
+
token_file = config.token_path()
|
|
130
|
+
auth.load_credentials(
|
|
131
|
+
credentials_file=creds_file,
|
|
132
|
+
token_file=token_file,
|
|
133
|
+
scopes=config.GMAIL_SCOPES,
|
|
134
|
+
)
|
|
135
|
+
print(f'Gmail credentials stored at {token_file}') # noqa: T201
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _run_once(
|
|
140
|
+
settings: settings_mod.Settings,
|
|
141
|
+
args: argparse.Namespace,
|
|
142
|
+
) -> int:
|
|
143
|
+
rpt = reporter.Reporter(mode=args.output)
|
|
144
|
+
return await agent.run(
|
|
145
|
+
settings=settings,
|
|
146
|
+
reporter=rpt,
|
|
147
|
+
query=args.query,
|
|
148
|
+
max_results=args.max_results,
|
|
149
|
+
dry_run=args.dry_run,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _run_daemon(
|
|
154
|
+
settings: settings_mod.Settings,
|
|
155
|
+
args: argparse.Namespace,
|
|
156
|
+
) -> int:
|
|
157
|
+
interval = args.interval
|
|
158
|
+
LOGGER.info('daemon mode: every %ds (Ctrl-C to stop)', interval)
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
rc = await _run_once(settings, args)
|
|
162
|
+
LOGGER.info('iteration finished (rc=%d)', rc)
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
raise
|
|
165
|
+
except Exception:
|
|
166
|
+
LOGGER.exception('iteration failed, continuing')
|
|
167
|
+
try:
|
|
168
|
+
await asyncio.sleep(interval)
|
|
169
|
+
except asyncio.CancelledError:
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
174
|
+
settings = settings_mod.Settings.load()
|
|
175
|
+
coro = (
|
|
176
|
+
_run_daemon(settings, args)
|
|
177
|
+
if args.daemon
|
|
178
|
+
else _run_once(settings, args)
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
return asyncio.run(coro)
|
|
182
|
+
except KeyboardInterrupt:
|
|
183
|
+
LOGGER.info('interrupted; shutting down')
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: list[str] | None = None) -> int:
|
|
188
|
+
parser = _build_parser()
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
_configure_logging(verbose=args.verbose, logfile=args.logfile)
|
|
191
|
+
match args.command:
|
|
192
|
+
case 'auth':
|
|
193
|
+
return _cmd_auth()
|
|
194
|
+
case 'run':
|
|
195
|
+
return _cmd_run(args)
|
|
196
|
+
case _: # pragma: no cover - argparse enforces choices
|
|
197
|
+
parser.error(f'unknown command: {args.command}')
|
|
198
|
+
return 2
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == '__main__':
|
|
202
|
+
sys.exit(main())
|
use_agent/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Paths and static defaults for use-agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
# Single scope. gmail.modify covers read, label changes, and send.
|
|
7
|
+
GMAIL_SCOPES: tuple[str, ...] = (
|
|
8
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
PROMPTS_DIR: pathlib.Path = pathlib.Path(__file__).parent / 'prompts'
|
|
12
|
+
CLASSIFIER_PROMPT: pathlib.Path = PROMPTS_DIR / 'classifier.md'
|
|
13
|
+
REPLY_PROMPT: pathlib.Path = PROMPTS_DIR / 'reply.md'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _config_home() -> pathlib.Path:
|
|
17
|
+
raw = os.environ.get('XDG_CONFIG_HOME')
|
|
18
|
+
if raw:
|
|
19
|
+
return pathlib.Path(raw) / 'use-agent'
|
|
20
|
+
return pathlib.Path.home() / '.config' / 'use-agent'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_path() -> pathlib.Path:
|
|
24
|
+
"""Path to the Google OAuth client secret JSON."""
|
|
25
|
+
override = os.environ.get('USE_AGENT_CREDENTIALS')
|
|
26
|
+
if override:
|
|
27
|
+
return pathlib.Path(override)
|
|
28
|
+
return _config_home() / 'credentials.json'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def token_path() -> pathlib.Path:
|
|
32
|
+
"""Path to the stored OAuth refresh token."""
|
|
33
|
+
override = os.environ.get('USE_AGENT_TOKEN')
|
|
34
|
+
if override:
|
|
35
|
+
return pathlib.Path(override)
|
|
36
|
+
return _config_home() / 'token.json'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_file_path() -> pathlib.Path:
|
|
40
|
+
"""Path to the TOML configuration file."""
|
|
41
|
+
override = os.environ.get('USE_AGENT_CONFIG')
|
|
42
|
+
if override:
|
|
43
|
+
return pathlib.Path(override)
|
|
44
|
+
return _config_home() / 'config.toml'
|