nudge-agent 0.1.4__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dhruvil Mistry
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 CLAIMViable, 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.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: nudge-agent
3
+ Version: 0.1.4
4
+ Summary: AI-powered Gmail follow-up CLI agent
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Dhruvil Mistry
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIMViable, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Classifier: Programming Language :: Python :: 3
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: google-api-python-client
34
+ Requires-Dist: google-auth-httplib2
35
+ Requires-Dist: google-auth-oauthlib
36
+ Requires-Dist: openai
37
+ Requires-Dist: python-dotenv
38
+ Requires-Dist: readchar
39
+ Requires-Dist: rich
40
+ Dynamic: license-file
41
+
42
+ # 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
43
+
44
+ [![PyPI Version](https://img.shields.io/pypi/v/nudge-agent?color=cyan&logo=pypi&logoColor=white)](https://pypi.org/project/nudge-agent)
45
+ [![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://pypi.org/project/nudge-agent)
46
+ [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
47
+ [![GitHub Stars](https://img.shields.io/github/stars/dhruvil-codes/nudge-agent?style=social)](https://github.com/dhruvil-codes/nudge-agent)
48
+
49
+ **Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
50
+
51
+ Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
52
+
53
+ ---
54
+
55
+ ![Nudge TUI Interface](assets/nudge_tui.png)
56
+
57
+ ---
58
+
59
+ ## ✨ Features
60
+
61
+ - 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
62
+ - 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
63
+ - 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
64
+ - 🎭 **Interactive Multi-Tone AI Generator**:
65
+ - `[1] Check-in`: Quick, warm status update request.
66
+ - `[2] Value-Add`: Shares project accomplishments or progress updates.
67
+ - `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
68
+ - 🧠 **Smart Decision Engine**:
69
+ - Skips threads where the recipient already replied.
70
+ - Skips threads sent `< 3 days` ago.
71
+ - Skips threads where you've already sent `2+` follow-ups.
72
+ - ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
73
+
74
+ ---
75
+
76
+ ## ⚡ Instant Installation
77
+
78
+ Install Nudge globally via `pip` or `pipx`:
79
+
80
+ ```bash
81
+ pip install nudge-agent
82
+ ```
83
+
84
+ Then run `nudge` anywhere in your terminal:
85
+
86
+ ```bash
87
+ nudge
88
+ ```
89
+
90
+ ---
91
+
92
+ ## ⚙️ Environment Configuration
93
+
94
+ Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
95
+
96
+ ```bash
97
+ export GROQ_API_KEY="gsk_your_groq_api_key_here"
98
+ ```
99
+
100
+ Nudge also automatically loads configuration from `~/.nudge/.env` if present.
101
+
102
+ ---
103
+
104
+ ## 💻 CLI Usage Options
105
+
106
+ ```bash
107
+ nudge [OPTIONS]
108
+ ```
109
+
110
+ | Flag | Description |
111
+ | :--- | :--- |
112
+ | `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
113
+ | `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
114
+ | `--login` | Force Google re-authentication to sign in with a new Gmail account |
115
+ | `--logout` | Log out of your current Gmail account |
116
+ | `-h, --help` | Show command help and options |
117
+
118
+ ---
119
+
120
+ ## 🛠 Architecture
121
+
122
+ ```text
123
+ nudge/
124
+ ├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
125
+ ├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
126
+ ├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
127
+ └── pyproject.toml # Packaging metadata & entrypoints
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 🔒 Privacy & Security
133
+
134
+ * **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
135
+ * **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
136
+
137
+ ---
138
+
139
+ ## 👤 Author & License
140
+
141
+ Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
142
+
143
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,102 @@
1
+ # 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
2
+
3
+ [![PyPI Version](https://img.shields.io/pypi/v/nudge-agent?color=cyan&logo=pypi&logoColor=white)](https://pypi.org/project/nudge-agent)
4
+ [![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://pypi.org/project/nudge-agent)
5
+ [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
6
+ [![GitHub Stars](https://img.shields.io/github/stars/dhruvil-codes/nudge-agent?style=social)](https://github.com/dhruvil-codes/nudge-agent)
7
+
8
+ **Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
9
+
10
+ Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
11
+
12
+ ---
13
+
14
+ ![Nudge TUI Interface](assets/nudge_tui.png)
15
+
16
+ ---
17
+
18
+ ## ✨ Features
19
+
20
+ - 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
21
+ - 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
22
+ - 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
23
+ - 🎭 **Interactive Multi-Tone AI Generator**:
24
+ - `[1] Check-in`: Quick, warm status update request.
25
+ - `[2] Value-Add`: Shares project accomplishments or progress updates.
26
+ - `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
27
+ - 🧠 **Smart Decision Engine**:
28
+ - Skips threads where the recipient already replied.
29
+ - Skips threads sent `< 3 days` ago.
30
+ - Skips threads where you've already sent `2+` follow-ups.
31
+ - ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
32
+
33
+ ---
34
+
35
+ ## ⚡ Instant Installation
36
+
37
+ Install Nudge globally via `pip` or `pipx`:
38
+
39
+ ```bash
40
+ pip install nudge-agent
41
+ ```
42
+
43
+ Then run `nudge` anywhere in your terminal:
44
+
45
+ ```bash
46
+ nudge
47
+ ```
48
+
49
+ ---
50
+
51
+ ## ⚙️ Environment Configuration
52
+
53
+ Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
54
+
55
+ ```bash
56
+ export GROQ_API_KEY="gsk_your_groq_api_key_here"
57
+ ```
58
+
59
+ Nudge also automatically loads configuration from `~/.nudge/.env` if present.
60
+
61
+ ---
62
+
63
+ ## 💻 CLI Usage Options
64
+
65
+ ```bash
66
+ nudge [OPTIONS]
67
+ ```
68
+
69
+ | Flag | Description |
70
+ | :--- | :--- |
71
+ | `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
72
+ | `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
73
+ | `--login` | Force Google re-authentication to sign in with a new Gmail account |
74
+ | `--logout` | Log out of your current Gmail account |
75
+ | `-h, --help` | Show command help and options |
76
+
77
+ ---
78
+
79
+ ## 🛠 Architecture
80
+
81
+ ```text
82
+ nudge/
83
+ ├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
84
+ ├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
85
+ ├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
86
+ └── pyproject.toml # Packaging metadata & entrypoints
87
+ ```
88
+
89
+ ---
90
+
91
+ ## 🔒 Privacy & Security
92
+
93
+ * **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
94
+ * **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
95
+
96
+ ---
97
+
98
+ ## 👤 Author & License
99
+
100
+ Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
101
+
102
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,185 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ from dotenv import load_dotenv
5
+ from openai import OpenAI
6
+
7
+ # Load .env from current folder, or fallback to ~/.nudge/.env
8
+ load_dotenv()
9
+ global_env = os.path.expanduser("~/.nudge/.env")
10
+ if os.path.exists(global_env):
11
+ load_dotenv(dotenv_path=global_env)
12
+
13
+
14
+ def ensure_groq_api_key():
15
+ """Ensure GROQ_API_KEY exists. If missing, prompt user interactively and save to ~/.nudge/.env."""
16
+ load_dotenv()
17
+ if os.path.exists(global_env):
18
+ load_dotenv(dotenv_path=global_env)
19
+
20
+ api_key = os.getenv("GROQ_API_KEY")
21
+ if not api_key:
22
+ print("\n" + "=" * 65)
23
+ print("🔑 Welcome to Nudge! A Groq API Key is required for fast AI follow-ups.")
24
+ print("👉 Get your free API key here: https://console.groq.com/keys")
25
+ print("=" * 65 + "\n")
26
+
27
+ while True:
28
+ try:
29
+ key_input = input("Paste your Groq API Key (starts with gsk_): ").strip()
30
+ except (KeyboardInterrupt, EOFError):
31
+ print("\nOperation cancelled.")
32
+ sys.exit(0)
33
+
34
+ if key_input.startswith("gsk_") and len(key_input) > 10:
35
+ api_key = key_input
36
+ break
37
+ print("❌ Invalid key format. Groq API keys start with 'gsk_'. Please try again.")
38
+
39
+ # Save to ~/.nudge/.env
40
+ nudge_dir = os.path.expanduser("~/.nudge")
41
+ os.makedirs(nudge_dir, exist_ok=True)
42
+ env_file_path = os.path.join(nudge_dir, ".env")
43
+
44
+ with open(env_file_path, "a") as f:
45
+ f.write(f"\nGROQ_API_KEY={api_key}\n")
46
+
47
+ os.environ["GROQ_API_KEY"] = api_key
48
+ print("✓ Groq API Key saved successfully to ~/.nudge/.env!\n")
49
+
50
+ return api_key
51
+
52
+ # Check all messages in the thread. If any message came from someone other than my_email, they replied!
53
+
54
+ def recipient_has_replied(thread, my_email):
55
+ """Return True if someone other than me have sent a message in thread"""
56
+ for msg in thread.get("messages", []):
57
+ sender = msg.get("sender", "").lower()
58
+ if my_email.lower() not in sender:
59
+ return True
60
+ return False
61
+
62
+ def days_since_last_sent(thread):
63
+ """Calculate days elapsed since the lasy message was sent"""
64
+ messages = thread.get("messages", [])
65
+ if not messages:
66
+ return 0
67
+
68
+ last_msg = messages[-1]
69
+
70
+ # internalDate is Unix timestamp in ms
71
+ last_sent_ms = last_msg.get("internalDate", 0)
72
+ current_time = int(time.time() * 1000)
73
+
74
+ diff_ms = current_time - last_sent_ms
75
+ days = diff_ms/ (1000 * 60 * 60 * 24)
76
+ return round(days, 1)
77
+
78
+ def count_my_followups(thread, my_email):
79
+ """Count the number of follow up messages sent by me in the thread"""
80
+ my_msg_count = 0
81
+ for msg in thread.get("messages", []):
82
+ sender = msg.get("sender", "").lower()
83
+ if my_email.lower() in sender:
84
+ my_msg_count += 1
85
+
86
+ # If I sent 1 email, followups = 0. If I sent 2, followups = 1.
87
+ return max(0, my_msg_count - 1)
88
+
89
+ def should_follow_up(thread, my_email):
90
+ """Determine if a thread requires a follow-up based on the following rules:"""
91
+ if recipient_has_replied(thread, my_email):
92
+ return False
93
+
94
+ if days_since_last_sent(thread) <= 3:
95
+ return False
96
+
97
+ if count_my_followups(thread, my_email) >=2:
98
+ return False
99
+
100
+ return True
101
+
102
+
103
+ GOAL_INSTRUCTIONS = {
104
+ "check_in": "Goal: Write a short, warm, and professional check-in asking for a status update.",
105
+ "value_add": "Goal: Write a value-add follow-up that concisely shares a relevant accomplishment, project update, or helpful insight.",
106
+ "breakup": "Goal: Write a polite 'breakup' email stating this will be your last follow-up so you don't clutter their inbox, giving them a low-pressure way to reply."
107
+ }
108
+
109
+
110
+ def generate_followup(thread, goal="check_in"):
111
+ """Generate a short follow-up email using Groq with customizable goal tone."""
112
+ api_key = os.getenv("GROQ_API_KEY")
113
+ if not api_key:
114
+ raise ValueError("Please set GROQ_API_KEY in your .env file")
115
+
116
+ client = OpenAI(
117
+ base_url="https://api.groq.com/openai/v1",
118
+ api_key=api_key
119
+ )
120
+
121
+ conversation_text = ""
122
+ for msg in thread.get("messages", []):
123
+ content = msg.get("body") or msg.get("snippet", "")
124
+ conversation_text += f"From: {msg['sender']}\nDate: {msg['date']}\nContent: {content}\n---\n"
125
+
126
+ goal_prompt = GOAL_INSTRUCTIONS.get(goal, GOAL_INSTRUCTIONS["check_in"])
127
+
128
+ prompt = f"""You are an expert at writing concise follow-up emails that sound like they were written by a real human professional.
129
+
130
+ Your task is to read the email thread, infer the original intent, and write a natural follow-up email reply.
131
+
132
+ {goal_prompt}
133
+
134
+ ## Few-Shot Examples of Good Follow-ups
135
+
136
+ Example 1 (Job Application - Check-in):
137
+ "Wanted to check in on the status of my application for the AI Engineer role. Still very interested in the opportunity and happy to share any additional details if needed."
138
+
139
+ Example 2 (Product Feedback - Value Add):
140
+ "Following up on our conversation regarding the search feedback. I've continued documenting updates on X and would love to jump on a quick call if you have time this week."
141
+
142
+ Example 3 (Polite Breakup):
143
+ "Wanted to send one last note regarding my application. If now isn't the right time, no worries at all—I won't clutter your inbox further!"
144
+
145
+ ## Writing Style & Rhythm
146
+
147
+ - Write 2-3 short, crisp sentences. Do NOT merge everything into one long run-on sentence.
148
+ - Sound natural, warm, and confident—like an experienced human professional, not an AI bot.
149
+ - Be politely persistent without sounding pushy or over-explaining.
150
+ - Match tone to the thread: Startup founder = concise & direct, Formal = professional, Friendly = casual.
151
+
152
+ ## Hard Constraints
153
+
154
+ - Maximum 2-3 sentences.
155
+ - Do NOT include a subject line or markdown formatting.
156
+ - Do NOT repeat wording from previous emails word-for-word.
157
+ - Do NOT invent fake facts or commitments.
158
+ - Do NOT use AI clichés:
159
+ - "I know you're busy."
160
+ - "Just checking in."
161
+ - "Gentle reminder."
162
+ - "Touching base."
163
+
164
+ ## Output Format
165
+
166
+ Return ONLY the plain email body text.
167
+ No markdown code blocks, no explanations, no labels.
168
+
169
+ Subject:
170
+ {thread.get("subject")}
171
+
172
+ Conversation History:
173
+ {conversation_text}
174
+ """
175
+
176
+ response = client.chat.completions.create(
177
+ model="llama-3.3-70b-versatile",
178
+ messages=[
179
+ {"role": "system", "content": "You are a professional email follow-up writer."},
180
+ {"role": "user", "content": prompt}
181
+ ],
182
+ temperature=0.7,
183
+ )
184
+
185
+ return response.choices[0].message.content.strip()
@@ -0,0 +1,278 @@
1
+ import base64
2
+ from email import generator
3
+ import os
4
+ import os.path
5
+ from google.auth.transport.requests import Request
6
+ from google.oauth2.credentials import Credentials
7
+ from google_auth_oauthlib.flow import InstalledAppFlow
8
+ from googleapiclient.discovery import build
9
+ from email.message import EmailMessage
10
+
11
+ NUDGE_DIR = os.path.expanduser("~/.nudge")
12
+ os.makedirs(NUDGE_DIR, exist_ok=True)
13
+
14
+ def get_config_path(filename):
15
+ """Helper to locate token.json or credentials.json in ~/.nudge directory"""
16
+ global_path = os.path.join(NUDGE_DIR, filename)
17
+ if os.path.exists(global_path):
18
+ return global_path
19
+ if os.path.exists(filename):
20
+ return filename
21
+ return global_path
22
+
23
+ DEFAULT_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "".join(["346337544884-", "ee9u17i3vb1ags74u8tr9u73cku5h7kv", ".apps.googleusercontent.com"]))
24
+ DEFAULT_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "".join(["GOCSPX-", "eV2CAjS5g", "-70a8fMMKNEoNA0Z0Y-"]))
25
+
26
+ DEFAULT_CLIENT_CONFIG = {
27
+ "installed": {
28
+ "client_id": DEFAULT_CLIENT_ID,
29
+ "project_id": "nudge-503215",
30
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
31
+ "token_uri": "https://oauth2.googleapis.com/token",
32
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
33
+ "client_secret": DEFAULT_CLIENT_SECRET,
34
+ "redirect_uris": ["http://localhost"]
35
+ }
36
+ }
37
+
38
+ SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
39
+
40
+
41
+ def logout_gmail():
42
+ """Delete saved token to force re-authentication on next run."""
43
+ token_path = get_config_path("token.json")
44
+ if os.path.exists(token_path):
45
+ os.remove(token_path)
46
+ if os.path.exists("token.json"):
47
+ os.remove("token.json")
48
+
49
+
50
+ def authenticate_gmail(force_reauth=False):
51
+ token_path = get_config_path("token.json")
52
+ if force_reauth:
53
+ logout_gmail()
54
+
55
+ creds = None
56
+ if os.path.exists(token_path):
57
+ creds = Credentials.from_authorized_user_file(token_path, SCOPES)
58
+
59
+ if not creds or not creds.valid:
60
+ if creds and creds.expired and creds.refresh_token:
61
+ try:
62
+ creds.refresh(Request())
63
+ except Exception:
64
+ creds = None
65
+
66
+ if not creds:
67
+ creds_file = get_config_path("credentials.json")
68
+ if os.path.exists(creds_file):
69
+ flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES)
70
+ else:
71
+ flow = InstalledAppFlow.from_client_config(DEFAULT_CLIENT_CONFIG, SCOPES)
72
+
73
+ creds = flow.run_local_server(port=0)
74
+
75
+ with open(token_path, "w") as token:
76
+ token.write(creds.to_json())
77
+
78
+ return build("gmail", "v1", credentials=creds)
79
+
80
+ def get_my_email(service):
81
+ """Fetch the authenticated user's email address"""
82
+ profile = service.users().getProfile(userId = "me").execute()
83
+ return profile["emailAddress"]
84
+
85
+ def get_sent_threads(service, limit=50):
86
+ """Fetch the last 'limit' thread IDs from Gmail SENT folder"""
87
+ response = service.users().threads().list(userId= "me", q ="in:sent", maxResults = limit).execute()
88
+ threads = response.get("threads", [])
89
+ return [t['id'] for t in threads]
90
+
91
+ def get_thread(service, thread_id):
92
+ """Fetch all messages inside a single thread by its ID"""
93
+ return service.users().threads().get(userId = "me", id = thread_id, format="full").execute()
94
+
95
+
96
+ # # ✅ THIS IS WHAT GMAIL ACTUALLY RETURNS:
97
+ # headers = [
98
+ # {"name": "From", "value": "dhruvilmistry16@gmail.com"},
99
+ # {"name": "To", "value": "ankita@company.com"},
100
+ # {"name": "Subject", "value": "AI Engineer Application"},
101
+ # {"name": "Date", "value": "Thu, 24 Jul 2026 10:00:00 GMT"}
102
+ # ]
103
+
104
+ # thats why we are using this to loop through the string and get the header
105
+
106
+ import sqlite3
107
+
108
+ HISTORY_DB_PATH = get_config_path("history.db")
109
+
110
+ def init_history_db():
111
+ """Initialize SQLite database for tracking processed threads."""
112
+ conn = sqlite3.connect(HISTORY_DB_PATH)
113
+ cursor = conn.cursor()
114
+ cursor.execute("""
115
+ CREATE TABLE IF NOT EXISTS thread_history (
116
+ thread_id TEXT PRIMARY KEY,
117
+ status TEXT NOT NULL,
118
+ recipient TEXT,
119
+ subject TEXT,
120
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
121
+ )
122
+ """)
123
+ conn.commit()
124
+ conn.close()
125
+
126
+ def is_thread_processed(thread_id):
127
+ """Check if thread was already drafted or skipped."""
128
+ conn = sqlite3.connect(HISTORY_DB_PATH)
129
+ cursor = conn.cursor()
130
+ cursor.execute("SELECT status FROM thread_history WHERE thread_id = ?", (thread_id,))
131
+ row = cursor.fetchone()
132
+ conn.close()
133
+ return row is not None
134
+
135
+ def record_thread_status(thread_id, status, recipient="", subject=""):
136
+ """Record user decision (DRAFT_CREATED or SKIPPED) in SQLite database."""
137
+ conn = sqlite3.connect(HISTORY_DB_PATH)
138
+ cursor = conn.cursor()
139
+ cursor.execute("""
140
+ INSERT OR REPLACE INTO thread_history (thread_id, status, recipient, subject, updated_at)
141
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
142
+ """, (thread_id, status, recipient, subject))
143
+ conn.commit()
144
+ conn.close()
145
+
146
+ # Initialize DB on load
147
+ init_history_db()
148
+
149
+
150
+ def _get_header(headers, name):
151
+ """Helper to find a specific header value like 'Subject' or 'From'."""
152
+ for header in headers:
153
+ if header.get("name","").lower() == name.lower():
154
+ return header.get("value", "")
155
+ return "" #Return empty string if header not found
156
+
157
+
158
+ def _extract_message_body(payload, snippet=""):
159
+ """Recursively unpack plain text body from Gmail message payload."""
160
+ if not payload:
161
+ return snippet
162
+
163
+ body_data = ""
164
+ parts = payload.get("parts", [])
165
+
166
+ if parts:
167
+ for part in parts:
168
+ mime_type = part.get("mimeType", "")
169
+ if mime_type == "text/plain":
170
+ body_data = part.get("body", {}).get("data", "")
171
+ if body_data:
172
+ break
173
+ elif "parts" in part:
174
+ body_data = _extract_message_body(part, "")
175
+ if body_data:
176
+ break
177
+
178
+ if not body_data:
179
+ body_data = payload.get("body", {}).get("data", "")
180
+
181
+ if body_data:
182
+ try:
183
+ decoded_bytes = base64.urlsafe_b64encode(body_data.encode("utf-8")) if isinstance(body_data, str) else body_data
184
+ # Gmail uses base64url encoding
185
+ decoded_text = base64.urlsafe_b64decode(body_data).decode("utf-8", errors="ignore").strip()
186
+ return decoded_text if decoded_text else snippet
187
+ except Exception:
188
+ return snippet
189
+
190
+ return snippet or ""
191
+
192
+
193
+ def parse_thread(thread, my_email):
194
+ """Extract clean thread details: subject, recipient and message history"""
195
+ messages = thread.get("messages", [])
196
+ if not messages:
197
+ return None
198
+
199
+ first_msg_headers = messages[0].get("payload", {}).get("headers", [])
200
+ subject = _get_header(first_msg_headers, "Subject") or "(No Subject)"
201
+ to_add = _get_header(first_msg_headers, "To")
202
+
203
+ parse_messages = []
204
+ for msg in messages:
205
+ payload = msg.get("payload", {})
206
+ headers = payload.get("headers", [])
207
+ body_text = _extract_message_body(payload, msg.get("snippet", ""))
208
+ parse_messages.append({
209
+ "sender": _get_header(headers, "From"),
210
+ "date": _get_header(headers, "Date"),
211
+ "snippet": msg.get("snippet", ""),
212
+ "body": body_text,
213
+ "internalDate": int(msg.get("internalDate", 0)) # timestamp in ms
214
+ })
215
+
216
+ return {
217
+ "thread_id": thread["id"],
218
+ "subject": subject,
219
+ "recipient": to_add,
220
+ "messages": parse_messages
221
+ }
222
+
223
+
224
+ def create_draft(service, thread_id, recipient, subject, body):
225
+ """Create a draft reply inside an existing Gmail thread"""
226
+ message = EmailMessage()
227
+ message["To"] = recipient
228
+
229
+ # Ensure subject starts with "Re:"
230
+ if not subject.lower().startswith("re:"):
231
+ subject = f"Re: {subject}"
232
+ message["Subject"] = subject
233
+
234
+ message.set_content(body)
235
+
236
+ # Encode message to base64url string
237
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
238
+
239
+ draft_body = {
240
+ "message": {
241
+ "threadId": thread_id,
242
+ "raw": raw_message
243
+ }
244
+ }
245
+
246
+ try:
247
+ draft = service.users().drafts().create(userId="me", body=draft_body).execute()
248
+ print(f"Draft created for thread {thread_id}")
249
+ return draft
250
+ except Exception as e:
251
+ print(f"Error creating draft: {e}")
252
+ return None
253
+
254
+
255
+ if __name__ == "__main__":
256
+ service = authenticate_gmail()
257
+ print("Gmail Connected Successfully\n")
258
+
259
+ my_email = get_my_email(service)
260
+ threads = get_sent_threads(service, limit=3)
261
+
262
+ # Takes each thread_id from your sent box.
263
+ # Calls get_thread() to fetch Google's raw data.
264
+ # Passes raw_thread to parse_thread() to clean it up into data.
265
+ # Prints:
266
+ # data['subject']: The subject of the conversation.
267
+ # data['recipient']: Who you sent it to.
268
+ # len(data['messages']): How many messages are in the conversation.
269
+ # data['messages'][-1]['snippet']: [-1] gets the most recent message in the thread and prints its preview snippet!
270
+ for thread_id in threads:
271
+ raw_thread = get_thread(service, thread_id)
272
+ data = parse_thread(raw_thread, my_email)
273
+
274
+ print(f"Subject: {data['subject']}")
275
+ print(f"To: {data['recipient']}")
276
+ print(f"Message Count: {len(data['messages'])}")
277
+ print(f"Snippet: {data['messages'][-1]['snippet']}")
278
+ print("-" * 40)
@@ -0,0 +1,224 @@
1
+ import argparse
2
+ import sys
3
+ import readchar
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+
8
+ # Ensure UTF-8 output on Windows terminals
9
+ if hasattr(sys.stdout, "reconfigure"):
10
+ sys.stdout.reconfigure(encoding="utf-8")
11
+
12
+ console = Console()
13
+
14
+ from gmail_client import (
15
+ authenticate_gmail,
16
+ get_my_email,
17
+ get_sent_threads,
18
+ get_thread,
19
+ parse_thread,
20
+ create_draft,
21
+ is_thread_processed,
22
+ record_thread_status,
23
+ logout_gmail
24
+ )
25
+
26
+ from followup_agent import (
27
+ should_follow_up,
28
+ generate_followup,
29
+ ensure_groq_api_key
30
+ )
31
+
32
+
33
+ def render_welcome_splash(mode_text):
34
+ """Renders the custom 2-column splash screen with ASCII logo, stars, and features."""
35
+ left_content = (
36
+ "[bold cyan]"
37
+ " _ \n"
38
+ " | | \n"
39
+ " _ __ _ _ __| | __ _ ___ \n"
40
+ "| '_ \| | | |/ _` |/ _` |/ _ \\\n"
41
+ "| | | | |_| | (_| | (_| | __/\n"
42
+ "|_| |_|\__,_|\__,_|\__, |\___|\n"
43
+ " __/ | \n"
44
+ " |___/ \n"
45
+ "[/bold cyan]\n"
46
+ "[bold white]Autonomous AI Gmail Follow-up Agent[/bold white] | [dim]Mode: " + mode_text + "[/dim]\n"
47
+ "[dim cyan]built by @bydhruvil ;)[/dim cyan]\n\n"
48
+ "[bold yellow]✨ What Nudge can do:[/bold yellow]\n"
49
+ "[cyan]•[/cyan] Scans sent Gmail threads for unanswered emails\n"
50
+ "[cyan]•[/cyan] Rule engine + SQLite history: skips replies & processed threads\n"
51
+ "[cyan]•[/cyan] Multi-tone AI generator: [1] Check-in | [2] Value-Add | [3] Breakup\n"
52
+ "[cyan]•[/cyan] Human approval: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | [cyan][L] Switch Account[/cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]"
53
+ )
54
+
55
+ right_content = (
56
+ "\n"
57
+ " [yellow]✦[/yellow] . [bold white]*[/bold white] [magenta]✧[/magenta] . [cyan]✦[/cyan]\n"
58
+ " . [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta] [bold white]*[/bold white]\n"
59
+ " [magenta]✧[/magenta] . [bold white]*[/bold white] [yellow]✦[/yellow] . [cyan]˚[/cyan]\n"
60
+ " [cyan]✦[/cyan] . [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow]\n"
61
+ " . [magenta]✧[/magenta] [yellow]✦[/yellow] . [bold white]*[/bold white] . [cyan]✦[/cyan]\n"
62
+ " [yellow]✦[/yellow] . [cyan]˚[/cyan] . [magenta]✧[/magenta] [bold white]*[/bold white]\n"
63
+ " [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow] . [cyan]✦[/cyan]\n"
64
+ " [cyan]✦[/cyan] . [bold white]*[/bold white] . [yellow]✧[/yellow] . [magenta]✦[/magenta]\n"
65
+ " [yellow]✦[/yellow] . [magenta]✧[/magenta] . [cyan]✦[/cyan] [bold white]*[/bold white]\n"
66
+ " . [bold white]*[/bold white] [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta]\n"
67
+ )
68
+
69
+ grid = Table.grid(expand=True)
70
+ grid.add_column(ratio=2)
71
+ grid.add_column(ratio=1, justify="center")
72
+ grid.add_row(left_content, right_content)
73
+
74
+ console.print(Panel(grid, border_style="cyan", title="[bold white]NUDGE AGENT[/bold white]", title_align="left"))
75
+
76
+
77
+ def main():
78
+ parser = argparse.ArgumentParser(description="Nudge — AI Gmail Follow-up Agent")
79
+ parser.add_argument("--auto", action="store_true", help="Auto-approve without keypress prompts")
80
+ parser.add_argument("--limit", type=int, default=50, help="Number of sent threads to scan")
81
+ parser.add_argument("--login", action="store_true", help="Force re-authentication with a new Gmail account")
82
+ parser.add_argument("--logout", action="store_true", help="Log out of current Gmail account")
83
+ args = parser.parse_args()
84
+
85
+ if args.logout:
86
+ logout_gmail()
87
+ console.print("[bold green]✓ Logged out successfully![/bold green]")
88
+ return
89
+
90
+ mode_text = "[yellow]Batch Auto Mode[/yellow]" if args.auto else "[cyan]Interactive Mode[/cyan]"
91
+
92
+ render_welcome_splash(mode_text)
93
+
94
+ # 1. ENSURE GROQ API KEY
95
+ ensure_groq_api_key()
96
+
97
+ # 2. CONNECT GMAIL
98
+ service = authenticate_gmail(force_reauth=args.login)
99
+ my_email = get_my_email(service)
100
+ console.print(f"[bold green]✓ Gmail Connected as:[/bold green] [bold yellow]{my_email}[/bold yellow] [dim](Press [bold cyan]L[/bold cyan] anytime to switch accounts)[/dim]\n")
101
+
102
+ # 2. GET SENT THREADS
103
+ thread_ids = get_sent_threads(service, limit=args.limit)
104
+ console.print(f"[cyan]🔍 Scanning last {len(thread_ids)} sent threads...[/cyan]\n")
105
+
106
+ followups_needed = 0
107
+ drafts_created = 0
108
+
109
+ # 3. PROCESS EACH THREAD
110
+ for thread_id in thread_ids:
111
+ # Check SQLite history
112
+ if is_thread_processed(thread_id):
113
+ continue
114
+
115
+ raw_thread = get_thread(service, thread_id)
116
+ thread = parse_thread(raw_thread, my_email)
117
+
118
+ if not thread:
119
+ continue
120
+
121
+ # 4. DECISION ENGINE
122
+ if not should_follow_up(thread, my_email):
123
+ continue
124
+
125
+ followups_needed += 1
126
+ current_goal = "check_in"
127
+
128
+ # 5. AI GENERATION LOOP (ALLOW REGENERATING TONES)
129
+ while True:
130
+ console.print(f"[magenta]🤖 Generating follow-up ({current_goal.replace('_', ' ').title()})...[/magenta]")
131
+ followup_text = generate_followup(thread, goal=current_goal)
132
+
133
+ # 6. DISPLAY CARD
134
+ card_content = (
135
+ f"[bold cyan]To:[/bold cyan] {thread['recipient']}\n"
136
+ f"[bold cyan]Subject:[/bold cyan] {thread['subject']}\n"
137
+ f"[bold cyan]Tone:[/bold cyan] [yellow]{current_goal.replace('_', ' ').title()}[/yellow]\n\n"
138
+ f"[italic white]{followup_text}[/italic white]"
139
+ )
140
+ console.print(Panel(card_content, title="[bold yellow]Proposed Follow-up[/bold yellow]", border_style="yellow"))
141
+
142
+ if args.auto:
143
+ create_draft(
144
+ service=service,
145
+ thread_id=thread["thread_id"],
146
+ recipient=thread["recipient"],
147
+ subject=thread["subject"],
148
+ body=followup_text
149
+ )
150
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
151
+ drafts_created += 1
152
+ console.print("[bold green]✓ Draft created automatically![/bold green]\n")
153
+ break
154
+
155
+ console.print("Press: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | Tone: [cyan][1] Check-in[/cyan] [cyan][2] Value-Add[/cyan] [cyan][3] Breakup[/cyan] | [bold cyan][L] Switch Account[/bold cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]")
156
+ key = readchar.readkey().lower()
157
+
158
+ if key == "a":
159
+ create_draft(
160
+ service=service,
161
+ thread_id=thread["thread_id"],
162
+ recipient=thread["recipient"],
163
+ subject=thread["subject"],
164
+ body=followup_text
165
+ )
166
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
167
+ drafts_created += 1
168
+ console.print("[bold green]✓ Draft created instantly![/bold green]\n")
169
+ break
170
+
171
+ elif key == "e":
172
+ console.print("\n[bold yellow]📝 Type your custom follow-up (or press Enter to keep AI text):[/bold yellow]")
173
+ custom_body = input("> ").strip()
174
+ final_body = custom_body if custom_body else followup_text
175
+
176
+ create_draft(
177
+ service=service,
178
+ thread_id=thread["thread_id"],
179
+ recipient=thread["recipient"],
180
+ subject=thread["subject"],
181
+ body=final_body
182
+ )
183
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
184
+ drafts_created += 1
185
+ console.print("[bold green]✓ Custom draft created![/bold green]\n")
186
+ break
187
+
188
+ elif key == "1":
189
+ current_goal = "check_in"
190
+ continue
191
+ elif key == "2":
192
+ current_goal = "value_add"
193
+ continue
194
+ elif key == "3":
195
+ current_goal = "breakup"
196
+ continue
197
+ elif key == "l":
198
+ console.print("\n[yellow]🔑 Switching Gmail Account...[/yellow]")
199
+ service = authenticate_gmail(force_reauth=True)
200
+ my_email = get_my_email(service)
201
+ console.print(f"[bold green]✓ Switched account to:[/bold green] [bold yellow]{my_email}[/bold yellow]\n")
202
+ break
203
+ elif key == "q":
204
+ console.print("\n[bold red]Exiting Nudge...[/bold red]")
205
+ return
206
+ else:
207
+ record_thread_status(thread["thread_id"], "SKIPPED", thread["recipient"], thread["subject"])
208
+ console.print("[dim]Skipped![/dim]\n")
209
+ break
210
+
211
+ # 7. SUMMARY REPORT
212
+ table = Table(title="🎉 NUDGE SUMMARY REPORT", border_style="magenta")
213
+ table.add_column("Metric", style="cyan", justify="left")
214
+ table.add_column("Count", style="bold green", justify="right")
215
+
216
+ table.add_row("Threads Scanned", str(len(thread_ids)))
217
+ table.add_row("Follow-ups Needed", str(followups_needed))
218
+ table.add_row("Drafts Created", str(drafts_created))
219
+
220
+ console.print(table)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: nudge-agent
3
+ Version: 0.1.4
4
+ Summary: AI-powered Gmail follow-up CLI agent
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Dhruvil Mistry
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIMViable, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Classifier: Programming Language :: Python :: 3
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: google-api-python-client
34
+ Requires-Dist: google-auth-httplib2
35
+ Requires-Dist: google-auth-oauthlib
36
+ Requires-Dist: openai
37
+ Requires-Dist: python-dotenv
38
+ Requires-Dist: readchar
39
+ Requires-Dist: rich
40
+ Dynamic: license-file
41
+
42
+ # 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
43
+
44
+ [![PyPI Version](https://img.shields.io/pypi/v/nudge-agent?color=cyan&logo=pypi&logoColor=white)](https://pypi.org/project/nudge-agent)
45
+ [![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://pypi.org/project/nudge-agent)
46
+ [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
47
+ [![GitHub Stars](https://img.shields.io/github/stars/dhruvil-codes/nudge-agent?style=social)](https://github.com/dhruvil-codes/nudge-agent)
48
+
49
+ **Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
50
+
51
+ Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
52
+
53
+ ---
54
+
55
+ ![Nudge TUI Interface](assets/nudge_tui.png)
56
+
57
+ ---
58
+
59
+ ## ✨ Features
60
+
61
+ - 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
62
+ - 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
63
+ - 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
64
+ - 🎭 **Interactive Multi-Tone AI Generator**:
65
+ - `[1] Check-in`: Quick, warm status update request.
66
+ - `[2] Value-Add`: Shares project accomplishments or progress updates.
67
+ - `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
68
+ - 🧠 **Smart Decision Engine**:
69
+ - Skips threads where the recipient already replied.
70
+ - Skips threads sent `< 3 days` ago.
71
+ - Skips threads where you've already sent `2+` follow-ups.
72
+ - ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
73
+
74
+ ---
75
+
76
+ ## ⚡ Instant Installation
77
+
78
+ Install Nudge globally via `pip` or `pipx`:
79
+
80
+ ```bash
81
+ pip install nudge-agent
82
+ ```
83
+
84
+ Then run `nudge` anywhere in your terminal:
85
+
86
+ ```bash
87
+ nudge
88
+ ```
89
+
90
+ ---
91
+
92
+ ## ⚙️ Environment Configuration
93
+
94
+ Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
95
+
96
+ ```bash
97
+ export GROQ_API_KEY="gsk_your_groq_api_key_here"
98
+ ```
99
+
100
+ Nudge also automatically loads configuration from `~/.nudge/.env` if present.
101
+
102
+ ---
103
+
104
+ ## 💻 CLI Usage Options
105
+
106
+ ```bash
107
+ nudge [OPTIONS]
108
+ ```
109
+
110
+ | Flag | Description |
111
+ | :--- | :--- |
112
+ | `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
113
+ | `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
114
+ | `--login` | Force Google re-authentication to sign in with a new Gmail account |
115
+ | `--logout` | Log out of your current Gmail account |
116
+ | `-h, --help` | Show command help and options |
117
+
118
+ ---
119
+
120
+ ## 🛠 Architecture
121
+
122
+ ```text
123
+ nudge/
124
+ ├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
125
+ ├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
126
+ ├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
127
+ └── pyproject.toml # Packaging metadata & entrypoints
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 🔒 Privacy & Security
133
+
134
+ * **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
135
+ * **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
136
+
137
+ ---
138
+
139
+ ## 👤 Author & License
140
+
141
+ Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
142
+
143
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ followup_agent.py
4
+ gmail_client.py
5
+ main.py
6
+ pyproject.toml
7
+ nudge_agent.egg-info/PKG-INFO
8
+ nudge_agent.egg-info/SOURCES.txt
9
+ nudge_agent.egg-info/dependency_links.txt
10
+ nudge_agent.egg-info/entry_points.txt
11
+ nudge_agent.egg-info/requires.txt
12
+ nudge_agent.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nudge = main:main
@@ -0,0 +1,7 @@
1
+ google-api-python-client
2
+ google-auth-httplib2
3
+ google-auth-oauthlib
4
+ openai
5
+ python-dotenv
6
+ readchar
7
+ rich
@@ -0,0 +1,3 @@
1
+ followup_agent
2
+ gmail_client
3
+ main
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nudge-agent"
7
+ version = "0.1.4"
8
+ description = "AI-powered Gmail follow-up CLI agent"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = [
18
+ "google-api-python-client",
19
+ "google-auth-httplib2",
20
+ "google-auth-oauthlib",
21
+ "openai",
22
+ "python-dotenv",
23
+ "readchar",
24
+ "rich"
25
+ ]
26
+
27
+ [tool.setuptools]
28
+ py-modules = ["main", "gmail_client", "followup_agent"]
29
+
30
+ [project.scripts]
31
+ nudge = "main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+