cursorbyemail 0.5.0
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.
- package/README.md +176 -0
- package/bin/cli.js +35 -0
- package/lib/check-cursor.js +22 -0
- package/lib/index.js +3 -0
- package/lib/init.js +221 -0
- package/lib/start.js +132 -0
- package/package.json +46 -0
- package/templates/beautify-transcript-email-html.js +322 -0
- package/templates/email-on-stop.mjs +110 -0
- package/templates/hooks.json +10 -0
- package/templates/webhook.mjs +150 -0
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# cursorbyemail
|
|
2
|
+
|
|
3
|
+
Email-based conversations with Cursor agents. Get notified via email when your Cursor agent finishes, and reply to continue the conversation.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
1. **Cursor hook** - When the Cursor agent finishes a task, a hook sends you an email with the agent's response
|
|
8
|
+
2. **Reply via email** - Reply to the email with your follow-up prompt
|
|
9
|
+
3. **Webhook receives reply** - Resend forwards your reply to a local webhook server exposed via ngrok
|
|
10
|
+
4. **Agent resumes** - The webhook triggers `cursor agent --resume` to continue the conversation
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
14
|
+
│ Cursor │────▶│ Resend │────▶│ Email │
|
|
15
|
+
│ Agent │ │ (send) │ │ Inbox │
|
|
16
|
+
└─────────────┘ └─────────────┘ └─────────────┘
|
|
17
|
+
▲ │
|
|
18
|
+
│ │ reply
|
|
19
|
+
│ ▼
|
|
20
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
21
|
+
│ cursor │◀────│ Webhook │◀────│ Resend │
|
|
22
|
+
│ --resume │ │ + ngrok │ │ (receive) │
|
|
23
|
+
└─────────────┘ └─────────────┘ └─────────────┘
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- Node.js 18+
|
|
29
|
+
- [Cursor CLI](https://cursor.com/es/docs/cli/installation) installed
|
|
30
|
+
- [Resend](https://resend.com) account with:
|
|
31
|
+
- API key
|
|
32
|
+
- Verified domain for sending
|
|
33
|
+
- Inbound email domain configured
|
|
34
|
+
- [ngrok](https://ngrok.com) account (free tier works)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install cursorbyemail
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
Run the interactive setup wizard:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx cursorbyemail init
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The wizard will:
|
|
51
|
+
|
|
52
|
+
1. Ask you to select your email provider (Resend)
|
|
53
|
+
2. Ask you to select your tunnel provider (ngrok)
|
|
54
|
+
3. Prompt for configuration values:
|
|
55
|
+
- `CURSOR_EMAIL_TO` - Your email address for notifications
|
|
56
|
+
- `CURSOR_EMAIL_SUBJECT` - Subject line prefix
|
|
57
|
+
- `RESEND_API_KEY` - Your Resend API key
|
|
58
|
+
- `RESEND_FROM` - Sender email address
|
|
59
|
+
- `RESEND_REPLY_TO` - Reply-to address (your Resend inbound domain)
|
|
60
|
+
- `NGROK_AUTHTOKEN` - ngrok auth token (optional if globally configured)
|
|
61
|
+
4. Create configuration files:
|
|
62
|
+
- `.env` with your settings
|
|
63
|
+
- `.cursor/hooks.json` for Cursor hooks
|
|
64
|
+
- `.cursor/hooks/` with hook scripts
|
|
65
|
+
5. Install required dependencies
|
|
66
|
+
6. Verify Cursor CLI is installed
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
### Start the webhook server
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx cursorbyemail start
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This will:
|
|
77
|
+
- Start an Express server on port 8787 (configurable)
|
|
78
|
+
- Create an ngrok tunnel
|
|
79
|
+
- Display the webhook URL to configure in Resend
|
|
80
|
+
|
|
81
|
+
### Configure Resend webhook
|
|
82
|
+
|
|
83
|
+
1. Go to [Resend Dashboard](https://resend.com/webhooks)
|
|
84
|
+
2. Add a new webhook endpoint
|
|
85
|
+
3. Use the URL displayed by `cursorbyemail start`:
|
|
86
|
+
```
|
|
87
|
+
https://<your-ngrok-url>/inbound/resend
|
|
88
|
+
```
|
|
89
|
+
4. Select the `email.received` event
|
|
90
|
+
|
|
91
|
+
### Use with Cursor
|
|
92
|
+
|
|
93
|
+
Just use Cursor as normal. When the agent finishes a task:
|
|
94
|
+
|
|
95
|
+
1. You'll receive an email with the agent's response
|
|
96
|
+
2. Reply to the email with your follow-up
|
|
97
|
+
3. The agent will resume and process your reply
|
|
98
|
+
4. You'll receive another email with the new response
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
### Environment Variables
|
|
103
|
+
|
|
104
|
+
| Variable | Description | Required |
|
|
105
|
+
|----------|-------------|----------|
|
|
106
|
+
| `CURSOR_EMAIL_ON_FINISH` | Enable email notifications (`1` to enable) | Yes |
|
|
107
|
+
| `CURSOR_EMAIL_TO` | Email recipient | Yes |
|
|
108
|
+
| `CURSOR_EMAIL_SUBJECT` | Subject line prefix | No |
|
|
109
|
+
| `RESEND_API_KEY` | Resend API key | Yes |
|
|
110
|
+
| `RESEND_FROM` | Sender email | Yes |
|
|
111
|
+
| `RESEND_REPLY_TO` | Reply-to address | Yes |
|
|
112
|
+
| `NGROK_AUTHTOKEN` | ngrok authentication token | No |
|
|
113
|
+
| `WEBHOOK_PORT` | Port for webhook server (default: 8787) | No |
|
|
114
|
+
| `DEFAULT_REPO_PATH` | Default repository path for agent | No |
|
|
115
|
+
| `DRY_RUN` | Test mode without executing agent | No |
|
|
116
|
+
| `CURSOR_DRY_RUN` | Test email hook without sending | No |
|
|
117
|
+
| `CURSOR_HOOK_DEBUG` | Enable debug logging | No |
|
|
118
|
+
|
|
119
|
+
### Customizing the hooks
|
|
120
|
+
|
|
121
|
+
The hook scripts are installed to `.cursor/hooks/`:
|
|
122
|
+
|
|
123
|
+
- `email-on-stop.mjs` - Sends email when agent finishes
|
|
124
|
+
- `webhook.mjs` - Receives inbound emails and resumes agent
|
|
125
|
+
- `beautify-transcript-email-html.js` - Formats emails
|
|
126
|
+
|
|
127
|
+
You can modify these files to customize behavior.
|
|
128
|
+
|
|
129
|
+
## CLI Commands
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Initialize in a project
|
|
133
|
+
npx cursorbyemail init
|
|
134
|
+
|
|
135
|
+
# Start webhook server with ngrok
|
|
136
|
+
npx cursorbyemail start
|
|
137
|
+
|
|
138
|
+
# Start on custom port
|
|
139
|
+
npx cursorbyemail start --port 3000
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Troubleshooting
|
|
143
|
+
|
|
144
|
+
### Cursor CLI not found
|
|
145
|
+
|
|
146
|
+
Install the Cursor CLI:
|
|
147
|
+
https://cursor.com/es/docs/cli/installation
|
|
148
|
+
|
|
149
|
+
### ngrok authentication error
|
|
150
|
+
|
|
151
|
+
Set your ngrok auth token:
|
|
152
|
+
```bash
|
|
153
|
+
# Add to .env
|
|
154
|
+
NGROK_AUTHTOKEN=your_token_here
|
|
155
|
+
|
|
156
|
+
# Or configure globally
|
|
157
|
+
ngrok config add-authtoken your_token_here
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Emails not sending
|
|
161
|
+
|
|
162
|
+
Check your Resend configuration:
|
|
163
|
+
- Verify your domain is properly configured
|
|
164
|
+
- Ensure the `RESEND_FROM` address uses your verified domain
|
|
165
|
+
- Check the `.cursor/hooks/hook.log` file for errors
|
|
166
|
+
|
|
167
|
+
### Webhook not receiving emails
|
|
168
|
+
|
|
169
|
+
Ensure:
|
|
170
|
+
- The ngrok tunnel is running (`npx cursorbyemail start`)
|
|
171
|
+
- The webhook URL is correctly configured in Resend
|
|
172
|
+
- The `email.received` event is selected in Resend
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("cursorbyemail")
|
|
15
|
+
.description("Email-based conversations with Cursor agents")
|
|
16
|
+
.version(pkg.version);
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command("init")
|
|
20
|
+
.description("Initialize cursorbyemail in your project")
|
|
21
|
+
.action(async () => {
|
|
22
|
+
const { init } = await import("../lib/init.js");
|
|
23
|
+
await init();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command("start")
|
|
28
|
+
.description("Start the webhook server with ngrok tunnel")
|
|
29
|
+
.option("-p, --port <port>", "Port for the webhook server", "8787")
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const { start } = await import("../lib/start.js");
|
|
32
|
+
await start({ port: parseInt(options.port, 10) });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program.parse();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export async function checkCursorCli() {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync("cursor", ["agent", "--version"], {
|
|
10
|
+
timeout: 10000,
|
|
11
|
+
});
|
|
12
|
+
const version = stdout.trim();
|
|
13
|
+
console.log(chalk.green(`✓ Cursor CLI found: ${version}`));
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.log(chalk.yellow("⚠ Cursor CLI not found or not working"));
|
|
17
|
+
console.log(chalk.dim(" The Cursor CLI is required for email reply functionality."));
|
|
18
|
+
console.log(chalk.dim(" Install it from: https://cursor.com/es/docs/cli/installation"));
|
|
19
|
+
console.log();
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/index.js
ADDED
package/lib/init.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { checkCursorCli } from "./check-cursor.js";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const templatesDir = join(__dirname, "..", "templates");
|
|
11
|
+
|
|
12
|
+
export async function init() {
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(chalk.bold("cursorbyemail") + " - Email-based conversations with Cursor agents");
|
|
15
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
16
|
+
console.log();
|
|
17
|
+
|
|
18
|
+
// Service selection
|
|
19
|
+
const serviceAnswers = await prompts([
|
|
20
|
+
{
|
|
21
|
+
type: "select",
|
|
22
|
+
name: "emailProvider",
|
|
23
|
+
message: "Select email provider:",
|
|
24
|
+
choices: [
|
|
25
|
+
{ title: "Resend", value: "resend", description: "Transactional email API" },
|
|
26
|
+
],
|
|
27
|
+
initial: 0,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "select",
|
|
31
|
+
name: "tunnelProvider",
|
|
32
|
+
message: "Select tunnel provider:",
|
|
33
|
+
choices: [
|
|
34
|
+
{ title: "ngrok", value: "ngrok", description: "Secure tunnels to localhost" },
|
|
35
|
+
],
|
|
36
|
+
initial: 0,
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
if (!serviceAnswers.emailProvider || !serviceAnswers.tunnelProvider) {
|
|
41
|
+
console.log(chalk.red("Setup cancelled."));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(chalk.bold("Environment Configuration"));
|
|
47
|
+
console.log(chalk.dim("These values will be stored in your .env file"));
|
|
48
|
+
console.log();
|
|
49
|
+
|
|
50
|
+
// Environment variables prompts
|
|
51
|
+
const envAnswers = await prompts([
|
|
52
|
+
{
|
|
53
|
+
type: "text",
|
|
54
|
+
name: "CURSOR_EMAIL_TO",
|
|
55
|
+
message: "Email address to receive notifications:",
|
|
56
|
+
validate: (v) => v.includes("@") || "Please enter a valid email address",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "text",
|
|
60
|
+
name: "CURSOR_EMAIL_SUBJECT",
|
|
61
|
+
message: "Email subject prefix:",
|
|
62
|
+
initial: "Cursor agent finished",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
type: "password",
|
|
66
|
+
name: "RESEND_API_KEY",
|
|
67
|
+
message: "Resend API key (starts with re_):",
|
|
68
|
+
validate: (v) => v.startsWith("re_") || "Resend API keys start with 're_'",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
name: "RESEND_FROM",
|
|
73
|
+
message: 'Sender email (e.g., "Agent <agent@domain.com>"):',
|
|
74
|
+
validate: (v) => v.includes("@") || "Please enter a valid sender email",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "text",
|
|
78
|
+
name: "RESEND_REPLY_TO",
|
|
79
|
+
message: 'Reply-to address for webhook (e.g., "domain@yoursubdomain.resend.app"):',
|
|
80
|
+
validate: (v) => v.includes("@") || "Please enter a valid reply-to address",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "text",
|
|
84
|
+
name: "NGROK_AUTHTOKEN",
|
|
85
|
+
message: "ngrok auth token (leave empty if globally configured):",
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
if (!envAnswers.CURSOR_EMAIL_TO || !envAnswers.RESEND_API_KEY) {
|
|
90
|
+
console.log(chalk.red("Setup cancelled."));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cwd = process.cwd();
|
|
95
|
+
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(chalk.bold("Setting up files..."));
|
|
98
|
+
console.log();
|
|
99
|
+
|
|
100
|
+
// Create .cursor/hooks directory
|
|
101
|
+
const cursorHooksDir = join(cwd, ".cursor", "hooks");
|
|
102
|
+
mkdirSync(cursorHooksDir, { recursive: true });
|
|
103
|
+
console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks/")}`);
|
|
104
|
+
|
|
105
|
+
// Copy hooks.json to .cursor/hooks.json
|
|
106
|
+
const hooksJsonSrc = join(templatesDir, "hooks.json");
|
|
107
|
+
const hooksJsonDest = join(cwd, ".cursor", "hooks.json");
|
|
108
|
+
copyFileSync(hooksJsonSrc, hooksJsonDest);
|
|
109
|
+
console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks.json")}`);
|
|
110
|
+
|
|
111
|
+
// Copy hook scripts
|
|
112
|
+
const hookFiles = [
|
|
113
|
+
"email-on-stop.mjs",
|
|
114
|
+
"beautify-transcript-email-html.js",
|
|
115
|
+
];
|
|
116
|
+
for (const file of hookFiles) {
|
|
117
|
+
copyFileSync(join(templatesDir, file), join(cursorHooksDir, file));
|
|
118
|
+
console.log(chalk.green("✓") + ` Created ${chalk.dim(`.cursor/hooks/${file}`)}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Copy webhook server
|
|
122
|
+
const webhookDest = join(cursorHooksDir, "webhook.mjs");
|
|
123
|
+
copyFileSync(join(templatesDir, "webhook.mjs"), webhookDest);
|
|
124
|
+
console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks/webhook.mjs")}`);
|
|
125
|
+
|
|
126
|
+
// Generate .env file
|
|
127
|
+
const envContent = `# cursorbyemail configuration
|
|
128
|
+
# Generated by npx cursorbyemail init
|
|
129
|
+
|
|
130
|
+
# Enable email notifications on agent finish
|
|
131
|
+
CURSOR_EMAIL_ON_FINISH=1
|
|
132
|
+
|
|
133
|
+
# Email recipient
|
|
134
|
+
CURSOR_EMAIL_TO="${envAnswers.CURSOR_EMAIL_TO}"
|
|
135
|
+
|
|
136
|
+
# Email subject prefix
|
|
137
|
+
CURSOR_EMAIL_SUBJECT="${envAnswers.CURSOR_EMAIL_SUBJECT}"
|
|
138
|
+
|
|
139
|
+
# Resend configuration
|
|
140
|
+
RESEND_API_KEY="${envAnswers.RESEND_API_KEY}"
|
|
141
|
+
RESEND_FROM="${envAnswers.RESEND_FROM}"
|
|
142
|
+
RESEND_REPLY_TO="${envAnswers.RESEND_REPLY_TO}"
|
|
143
|
+
|
|
144
|
+
# ngrok configuration (optional if globally configured)
|
|
145
|
+
${envAnswers.NGROK_AUTHTOKEN ? `NGROK_AUTHTOKEN="${envAnswers.NGROK_AUTHTOKEN}"` : "# NGROK_AUTHTOKEN="}
|
|
146
|
+
|
|
147
|
+
# Webhook server port
|
|
148
|
+
WEBHOOK_PORT=8787
|
|
149
|
+
|
|
150
|
+
# Default repository path for webhook (defaults to cwd)
|
|
151
|
+
DEFAULT_REPO_PATH="${cwd}"
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const envPath = join(cwd, ".env");
|
|
155
|
+
const envExists = existsSync(envPath);
|
|
156
|
+
|
|
157
|
+
if (envExists) {
|
|
158
|
+
const existingEnv = readFileSync(envPath, "utf8");
|
|
159
|
+
writeFileSync(envPath, existingEnv + "\n" + envContent);
|
|
160
|
+
console.log(chalk.green("✓") + ` Appended to ${chalk.dim(".env")}`);
|
|
161
|
+
} else {
|
|
162
|
+
writeFileSync(envPath, envContent);
|
|
163
|
+
console.log(chalk.green("✓") + ` Created ${chalk.dim(".env")}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Add script to package.json if it exists
|
|
167
|
+
const pkgPath = join(cwd, "package.json");
|
|
168
|
+
if (existsSync(pkgPath)) {
|
|
169
|
+
try {
|
|
170
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
171
|
+
pkg.scripts = pkg.scripts || {};
|
|
172
|
+
pkg.scripts["cursor:email-hook"] = "node .cursor/hooks/email-on-stop.mjs";
|
|
173
|
+
pkg.scripts["cursor:webhook"] = "node .cursor/hooks/webhook.mjs";
|
|
174
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
175
|
+
console.log(chalk.green("✓") + ` Added scripts to ${chalk.dim("package.json")}`);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.log(chalk.yellow("⚠") + ` Could not update package.json: ${e.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log();
|
|
182
|
+
console.log(chalk.bold("Installing dependencies..."));
|
|
183
|
+
console.log();
|
|
184
|
+
|
|
185
|
+
// Install required dependencies
|
|
186
|
+
try {
|
|
187
|
+
execSync("npm install --save resend dotenv express", {
|
|
188
|
+
cwd,
|
|
189
|
+
stdio: "inherit",
|
|
190
|
+
});
|
|
191
|
+
console.log();
|
|
192
|
+
console.log(chalk.green("✓") + " Dependencies installed");
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.log(chalk.yellow("⚠") + " Failed to install dependencies. Run manually:");
|
|
195
|
+
console.log(chalk.dim(" npm install resend dotenv express"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log();
|
|
199
|
+
console.log(chalk.bold("Checking Cursor CLI..."));
|
|
200
|
+
console.log();
|
|
201
|
+
|
|
202
|
+
// Check Cursor CLI
|
|
203
|
+
await checkCursorCli();
|
|
204
|
+
|
|
205
|
+
// Final instructions
|
|
206
|
+
console.log();
|
|
207
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
208
|
+
console.log(chalk.bold.green("Setup complete!"));
|
|
209
|
+
console.log();
|
|
210
|
+
console.log("Next steps:");
|
|
211
|
+
console.log();
|
|
212
|
+
console.log(chalk.dim("1.") + " Configure Resend webhook to point to your ngrok URL:");
|
|
213
|
+
console.log(chalk.cyan(" npx cursorbyemail start"));
|
|
214
|
+
console.log();
|
|
215
|
+
console.log(chalk.dim("2.") + " In Resend dashboard, add webhook endpoint:");
|
|
216
|
+
console.log(chalk.dim(" https://<your-ngrok-url>/inbound/resend"));
|
|
217
|
+
console.log();
|
|
218
|
+
console.log(chalk.dim("3.") + " Start using Cursor - emails will be sent on agent completion");
|
|
219
|
+
console.log(chalk.dim(" and replies will resume the conversation."));
|
|
220
|
+
console.log();
|
|
221
|
+
}
|
package/lib/start.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ngrok from "ngrok";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export async function start({ port = 8787 } = {}) {
|
|
9
|
+
const webhookPort = process.env.WEBHOOK_PORT || port;
|
|
10
|
+
|
|
11
|
+
console.log();
|
|
12
|
+
console.log(chalk.bold("cursorbyemail") + " - Starting webhook server");
|
|
13
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
14
|
+
console.log();
|
|
15
|
+
|
|
16
|
+
// Find webhook script
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const webhookPaths = [
|
|
19
|
+
join(cwd, ".cursor", "hooks", "webhook.mjs"),
|
|
20
|
+
join(cwd, "webhook.mjs"),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
let webhookPath = null;
|
|
24
|
+
for (const p of webhookPaths) {
|
|
25
|
+
if (existsSync(p)) {
|
|
26
|
+
webhookPath = p;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!webhookPath) {
|
|
32
|
+
console.log(chalk.red("✗ webhook.mjs not found"));
|
|
33
|
+
console.log(chalk.dim(" Run 'npx cursorbyemail init' first to set up your project"));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(chalk.dim(`Using webhook: ${webhookPath}`));
|
|
38
|
+
console.log();
|
|
39
|
+
|
|
40
|
+
// Start Express server
|
|
41
|
+
console.log(chalk.bold("Starting webhook server..."));
|
|
42
|
+
|
|
43
|
+
const serverProcess = spawn("node", [webhookPath], {
|
|
44
|
+
env: { ...process.env, WEBHOOK_PORT: String(webhookPort) },
|
|
45
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
46
|
+
cwd,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
serverProcess.stdout.on("data", (data) => {
|
|
50
|
+
process.stdout.write(chalk.dim(`[server] ${data}`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
serverProcess.stderr.on("data", (data) => {
|
|
54
|
+
process.stderr.write(chalk.red(`[server] ${data}`));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
serverProcess.on("error", (err) => {
|
|
58
|
+
console.log(chalk.red(`✗ Failed to start server: ${err.message}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Wait for server to start
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
64
|
+
|
|
65
|
+
console.log(chalk.green("✓") + ` Webhook server running on port ${webhookPort}`);
|
|
66
|
+
console.log();
|
|
67
|
+
|
|
68
|
+
// Start ngrok tunnel
|
|
69
|
+
console.log(chalk.bold("Starting ngrok tunnel..."));
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const ngrokOptions = {
|
|
73
|
+
addr: webhookPort,
|
|
74
|
+
proto: "http",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Use auth token from env if available
|
|
78
|
+
if (process.env.NGROK_AUTHTOKEN) {
|
|
79
|
+
ngrokOptions.authtoken = process.env.NGROK_AUTHTOKEN;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = await ngrok.connect(ngrokOptions);
|
|
83
|
+
|
|
84
|
+
console.log(chalk.green("✓") + " ngrok tunnel established");
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(chalk.bold("Webhook URL:"));
|
|
89
|
+
console.log(chalk.cyan.bold(` ${url}/inbound/resend`));
|
|
90
|
+
console.log();
|
|
91
|
+
console.log(chalk.dim("Add this URL to your Resend webhook configuration:"));
|
|
92
|
+
console.log(chalk.dim(" 1. Go to Resend dashboard → Webhooks"));
|
|
93
|
+
console.log(chalk.dim(" 2. Add endpoint with URL above"));
|
|
94
|
+
console.log(chalk.dim(" 3. Select 'email.received' event"));
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(chalk.dim("Press Ctrl+C to stop"));
|
|
97
|
+
console.log();
|
|
98
|
+
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.log(chalk.red(`✗ Failed to start ngrok: ${err.message}`));
|
|
101
|
+
console.log();
|
|
102
|
+
|
|
103
|
+
if (err.message.includes("authtoken")) {
|
|
104
|
+
console.log(chalk.dim("To fix this:"));
|
|
105
|
+
console.log(chalk.dim(" 1. Get your authtoken from https://dashboard.ngrok.com/get-started/your-authtoken"));
|
|
106
|
+
console.log(chalk.dim(" 2. Add NGROK_AUTHTOKEN to your .env file"));
|
|
107
|
+
console.log(chalk.dim(" Or run: ngrok config add-authtoken <token>"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
serverProcess.kill();
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle shutdown
|
|
115
|
+
const shutdown = async () => {
|
|
116
|
+
console.log();
|
|
117
|
+
console.log(chalk.dim("Shutting down..."));
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await ngrok.disconnect();
|
|
121
|
+
await ngrok.kill();
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// Ignore ngrok shutdown errors
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
serverProcess.kill();
|
|
127
|
+
process.exit(0);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
process.on("SIGINT", shutdown);
|
|
131
|
+
process.on("SIGTERM", shutdown);
|
|
132
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cursorbyemail",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Email-based conversations with Cursor agents via Resend webhooks and ngrok tunneling",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cursorbyemail": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "lib/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node lib/start.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"cursor",
|
|
15
|
+
"email",
|
|
16
|
+
"agent",
|
|
17
|
+
"resend",
|
|
18
|
+
"ngrok",
|
|
19
|
+
"webhook",
|
|
20
|
+
"ai",
|
|
21
|
+
"automation"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chalk": "^5.3.0",
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"dotenv": "^16.4.5",
|
|
29
|
+
"express": "^4.21.0",
|
|
30
|
+
"ngrok": "^5.0.0-beta.2",
|
|
31
|
+
"prompts": "^2.4.2",
|
|
32
|
+
"resend": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"bin/",
|
|
39
|
+
"lib/",
|
|
40
|
+
"templates/"
|
|
41
|
+
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beautify a Cursor transcript (raw text) into HTML suitable for email.
|
|
3
|
+
* - Handles "user:" / "assistant:" turns
|
|
4
|
+
* - Renders <user_query> blocks
|
|
5
|
+
* - Renders <code_selection ...> blocks with header + line numbers
|
|
6
|
+
* - Collapses <attached_files> placeholder
|
|
7
|
+
* - Escapes HTML, preserves spacing, wraps long code lines
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const html = beautifyTranscriptToEmailHTML(transcriptText, { title: "Cursor Agent Result" });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const css = `
|
|
14
|
+
:root{color-scheme:light}
|
|
15
|
+
body{margin:0;padding:0;background:#ffffff;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial}
|
|
16
|
+
.wrap{max-width:900px;margin:0 auto;padding:24px}
|
|
17
|
+
.card{border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;background:#fff}
|
|
18
|
+
.card + .card{margin-top:18px}
|
|
19
|
+
.hdr{padding:16px 18px;border-bottom:1px solid #e5e7eb;background:#f9fafb}
|
|
20
|
+
.hdr h1{margin:0;font-size:16px;line-height:1.3;font-weight:700;color:#111827}
|
|
21
|
+
.hdr .meta{margin-top:6px;font-size:12px;color:#6b7280}
|
|
22
|
+
.turn{padding:16px 18px;border-top:1px solid #f3f4f6}
|
|
23
|
+
.turn:first-child{border-top:none}
|
|
24
|
+
.role{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 8px;border-radius:999px}
|
|
25
|
+
.role.user{background:#eff6ff;color:#1d4ed8}
|
|
26
|
+
.role.assistant{background:#ecfdf5;color:#047857}
|
|
27
|
+
.content{margin-top:10px;font-size:13px;line-height:1.55;color:#111827}
|
|
28
|
+
.p{margin:10px 0}
|
|
29
|
+
.muted{color:#6b7280}
|
|
30
|
+
.box{border:1px solid #e5e7eb;border-radius:10px;background:#fafafa;padding:12px;margin:12px 0}
|
|
31
|
+
.qtitle{font-size:12px;font-weight:700;color:#111827;margin:0 0 8px 0}
|
|
32
|
+
.codehdr{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px}
|
|
33
|
+
.badge{font-size:11px;background:#f3f4f6;color:#111827;border:1px solid #e5e7eb;border-radius:999px;padding:3px 8px}
|
|
34
|
+
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;color:#0f172a}
|
|
35
|
+
.lines{counter-reset:ln}
|
|
36
|
+
.ln{display:block}
|
|
37
|
+
.ln:before{counter-increment:ln;content:counter(ln);display:inline-block;width:2.7em;margin-right:12px;text-align:right;color:#94a3b8}
|
|
38
|
+
.raw{margin-top:18px;border-top:1px dashed #e5e7eb;padding-top:14px}
|
|
39
|
+
.raw h2{margin:0 0 8px 0;font-size:12px;color:#6b7280;font-weight:700}
|
|
40
|
+
`.trim();
|
|
41
|
+
|
|
42
|
+
const esc = (s) =>
|
|
43
|
+
String(s)
|
|
44
|
+
.replaceAll("&", "&")
|
|
45
|
+
.replaceAll("<", "<")
|
|
46
|
+
.replaceAll(">", ">")
|
|
47
|
+
.replaceAll('"', """)
|
|
48
|
+
.replaceAll("'", "'");
|
|
49
|
+
|
|
50
|
+
const safeJson = (obj) => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(obj, null, 2);
|
|
53
|
+
} catch {
|
|
54
|
+
return String(obj);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const normalizeNewlines = (text) => String(text ?? "").replace(/\r\n/g, "\n");
|
|
59
|
+
|
|
60
|
+
const splitTurns = (src) => {
|
|
61
|
+
const turns = [];
|
|
62
|
+
const re = /^(user|assistant):[ \t]*$/gim;
|
|
63
|
+
let lastIndex = 0;
|
|
64
|
+
let m;
|
|
65
|
+
let currentRole = null;
|
|
66
|
+
|
|
67
|
+
while ((m = re.exec(src)) !== null) {
|
|
68
|
+
const role = m[1].toLowerCase();
|
|
69
|
+
const markerStart = m.index;
|
|
70
|
+
if (currentRole !== null) {
|
|
71
|
+
const chunk = src.slice(lastIndex, markerStart).trim();
|
|
72
|
+
turns.push({ role: currentRole, text: chunk });
|
|
73
|
+
}
|
|
74
|
+
currentRole = role;
|
|
75
|
+
lastIndex = re.lastIndex;
|
|
76
|
+
}
|
|
77
|
+
if (currentRole !== null) {
|
|
78
|
+
turns.push({ role: currentRole, text: src.slice(lastIndex).trim() });
|
|
79
|
+
} else {
|
|
80
|
+
turns.push({ role: "assistant", text: src.trim() });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return turns;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const renderInlineBlocks = (text) => {
|
|
87
|
+
let t = String(text ?? "");
|
|
88
|
+
|
|
89
|
+
// Replace <attached_files> placeholder
|
|
90
|
+
t = t.replace(/<attached_files>\s*<\/attached_files>|<attached_files>\s*/gi, "\n[attached_files]\n");
|
|
91
|
+
|
|
92
|
+
// Extract <user_query> blocks
|
|
93
|
+
const uq = [];
|
|
94
|
+
t = t.replace(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/gi, (_, inner) => {
|
|
95
|
+
uq.push(inner.trim());
|
|
96
|
+
return "\n{{{__USER_QUERY__}}}\n";
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Extract <code_selection ...> blocks
|
|
100
|
+
const codeSelections = [];
|
|
101
|
+
t = t.replace(
|
|
102
|
+
/<code_selection\b([^>]*)>\s*([\s\S]*?)\s*<\/code_selection>/gi,
|
|
103
|
+
(_, attrs, inner) => {
|
|
104
|
+
const pathMatch = attrs.match(/\bpath="([^"]+)"/i);
|
|
105
|
+
const linesMatch = attrs.match(/\blines="([^"]+)"/i);
|
|
106
|
+
codeSelections.push({
|
|
107
|
+
path: pathMatch ? pathMatch[1] : "",
|
|
108
|
+
lines: linesMatch ? linesMatch[1] : "",
|
|
109
|
+
code: inner.replace(/\s+$/g, ""),
|
|
110
|
+
});
|
|
111
|
+
return "\n{{{__CODE_SELECTION__}}}\n";
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Escape everything, then re-inject safe HTML blocks.
|
|
116
|
+
const parts = esc(t).split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
117
|
+
|
|
118
|
+
const blocks = [];
|
|
119
|
+
let uqIdx = 0;
|
|
120
|
+
let csIdx = 0;
|
|
121
|
+
|
|
122
|
+
for (const p of parts) {
|
|
123
|
+
if (p.includes("{{{__USER_QUERY__}}}")) {
|
|
124
|
+
const q = uq[uqIdx++] ?? "";
|
|
125
|
+
blocks.push(`
|
|
126
|
+
<div class="box">
|
|
127
|
+
<div class="qtitle">User query</div>
|
|
128
|
+
<div class="content"><pre>${esc(q)}</pre></div>
|
|
129
|
+
</div>
|
|
130
|
+
`.trim());
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (p.includes("{{{__CODE_SELECTION__}}}")) {
|
|
134
|
+
const cs = codeSelections[csIdx++] ?? { path: "", lines: "", code: "" };
|
|
135
|
+
|
|
136
|
+
// Parse line-numbered code like "100| ..."
|
|
137
|
+
const rawLines = String(cs.code ?? "").split("\n");
|
|
138
|
+
const normalized = rawLines
|
|
139
|
+
.map((ln) => ln.replace(/^\s*\d+\|\s?/, ""))
|
|
140
|
+
.join("\n")
|
|
141
|
+
.trimEnd();
|
|
142
|
+
|
|
143
|
+
blocks.push(`
|
|
144
|
+
<div class="box">
|
|
145
|
+
<div class="codehdr">
|
|
146
|
+
${cs.path ? `<span class="badge">${esc(cs.path)}</span>` : ""}
|
|
147
|
+
${cs.lines ? `<span class="badge">lines ${esc(cs.lines)}</span>` : ""}
|
|
148
|
+
</div>
|
|
149
|
+
<pre class="lines">${esc(normalized)
|
|
150
|
+
.split("\n")
|
|
151
|
+
.map((line) => `<span class="ln">${line || " "}</span>`)
|
|
152
|
+
.join("\n")}</pre>
|
|
153
|
+
</div>
|
|
154
|
+
`.trim());
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
blocks.push(`<div class="p"><pre>${p}</pre></div>`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return blocks.join("\n");
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const renderTurns = (transcript) => {
|
|
165
|
+
const src = normalizeNewlines(transcript);
|
|
166
|
+
const turns = splitTurns(src);
|
|
167
|
+
|
|
168
|
+
return turns
|
|
169
|
+
.filter((t) => t.text && t.text.length)
|
|
170
|
+
.map((t) => {
|
|
171
|
+
const roleClass = t.role === "user" ? "user" : "assistant";
|
|
172
|
+
return `
|
|
173
|
+
<div class="turn">
|
|
174
|
+
<span class="role ${roleClass}">${esc(t.role)}</span>
|
|
175
|
+
<div class="content">
|
|
176
|
+
${renderInlineBlocks(t.text)}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
`.trim();
|
|
180
|
+
})
|
|
181
|
+
.join("\n");
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const renderCard = ({ title, metaLines = [], bodyHtml }) => {
|
|
185
|
+
const meta = metaLines
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((line) => `<div class="meta">${esc(line)}</div>`)
|
|
188
|
+
.join("");
|
|
189
|
+
|
|
190
|
+
return `
|
|
191
|
+
<div class="card">
|
|
192
|
+
<div class="hdr">
|
|
193
|
+
<h1>${esc(title)}</h1>
|
|
194
|
+
${meta}
|
|
195
|
+
</div>
|
|
196
|
+
${bodyHtml}
|
|
197
|
+
</div>
|
|
198
|
+
`.trim();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const renderDocument = ({ cardsHtml, rawFallback = "" }) => `
|
|
202
|
+
<!doctype html>
|
|
203
|
+
<html>
|
|
204
|
+
<head>
|
|
205
|
+
<meta charset="utf-8" />
|
|
206
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
207
|
+
<style>${css}</style>
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
<div class="wrap">
|
|
211
|
+
${cardsHtml}
|
|
212
|
+
${rawFallback}
|
|
213
|
+
</div>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|
|
216
|
+
`.trim();
|
|
217
|
+
|
|
218
|
+
export function extractLatestResponseFromPayload(payload, rawFallback = "") {
|
|
219
|
+
const candidates = [
|
|
220
|
+
payload?.text,
|
|
221
|
+
payload?.finalResponse,
|
|
222
|
+
payload?.response,
|
|
223
|
+
payload?.message,
|
|
224
|
+
payload?.summary,
|
|
225
|
+
payload?.output,
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
for (const c of candidates) {
|
|
229
|
+
if (!c) continue;
|
|
230
|
+
if (typeof c === "string") return c;
|
|
231
|
+
return safeJson(c);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return rawFallback || "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getTranscriptPathFromPayload(payload) {
|
|
238
|
+
return payload?.transcript_path || payload?.transcriptPath || "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function beautifyTranscriptToEmailHTML(
|
|
242
|
+
transcript,
|
|
243
|
+
{
|
|
244
|
+
title = "Agent transcript",
|
|
245
|
+
includeRawFallback = true,
|
|
246
|
+
maxRawFallbackChars = 15000,
|
|
247
|
+
} = {}
|
|
248
|
+
) {
|
|
249
|
+
const src = normalizeNewlines(transcript);
|
|
250
|
+
const renderedTurns = renderTurns(src);
|
|
251
|
+
|
|
252
|
+
const card = renderCard({
|
|
253
|
+
title,
|
|
254
|
+
metaLines: [new Date().toISOString()],
|
|
255
|
+
bodyHtml:
|
|
256
|
+
renderedTurns ||
|
|
257
|
+
`<div class="turn"><div class="content muted">No transcript content.</div></div>`,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const rawFallback =
|
|
261
|
+
includeRawFallback && src
|
|
262
|
+
? `
|
|
263
|
+
<div class="raw">
|
|
264
|
+
<h2>Raw transcript (truncated)</h2>
|
|
265
|
+
<pre>${esc(src.slice(0, maxRawFallbackChars))}${
|
|
266
|
+
src.length > maxRawFallbackChars ? "\n...(truncated)" : ""
|
|
267
|
+
}</pre>
|
|
268
|
+
</div>
|
|
269
|
+
`.trim()
|
|
270
|
+
: "";
|
|
271
|
+
|
|
272
|
+
return renderDocument({ cardsHtml: card, rawFallback });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function beautifyPayloadToEmailHTML(
|
|
276
|
+
payload,
|
|
277
|
+
{
|
|
278
|
+
title = "Cursor agent finished",
|
|
279
|
+
includeRawFallback = true,
|
|
280
|
+
maxRawFallbackChars = 15000,
|
|
281
|
+
transcriptText = "",
|
|
282
|
+
latestResponse,
|
|
283
|
+
transcriptPath,
|
|
284
|
+
} = {}
|
|
285
|
+
) {
|
|
286
|
+
const responseText = latestResponse ?? extractLatestResponseFromPayload(payload);
|
|
287
|
+
const fullTranscript = transcriptText ?? payload?.transcript ?? "";
|
|
288
|
+
const path = transcriptPath ?? getTranscriptPathFromPayload(payload);
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
|
|
291
|
+
const responseTurns = renderTurns(`assistant:\n${responseText || ""}`);
|
|
292
|
+
const responseCard = renderCard({
|
|
293
|
+
title: "Latest response",
|
|
294
|
+
metaLines: [now],
|
|
295
|
+
bodyHtml:
|
|
296
|
+
responseTurns ||
|
|
297
|
+
`<div class="turn"><div class="content muted">No latest response.</div></div>`,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const transcriptTurns = renderTurns(fullTranscript);
|
|
301
|
+
const transcriptCard = renderCard({
|
|
302
|
+
title: title || "Full transcript",
|
|
303
|
+
metaLines: [now, path ? `Transcript path: ${path}` : "Transcript path: (missing)"],
|
|
304
|
+
bodyHtml:
|
|
305
|
+
transcriptTurns ||
|
|
306
|
+
`<div class="turn"><div class="content muted">No transcript content.</div></div>`,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const rawFallback =
|
|
310
|
+
includeRawFallback && fullTranscript
|
|
311
|
+
? `
|
|
312
|
+
<div class="raw">
|
|
313
|
+
<h2>Raw transcript (truncated)</h2>
|
|
314
|
+
<pre>${esc(fullTranscript.slice(0, maxRawFallbackChars))}${
|
|
315
|
+
fullTranscript.length > maxRawFallbackChars ? "\n...(truncated)" : ""
|
|
316
|
+
}</pre>
|
|
317
|
+
</div>
|
|
318
|
+
`.trim()
|
|
319
|
+
: "";
|
|
320
|
+
|
|
321
|
+
return renderDocument({ cardsHtml: [responseCard, transcriptCard].join("\n"), rawFallback });
|
|
322
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { Resend } from "resend";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
beautifyPayloadToEmailHTML,
|
|
7
|
+
extractLatestResponseFromPayload,
|
|
8
|
+
getTranscriptPathFromPayload,
|
|
9
|
+
} from "./beautify-transcript-email-html.js";
|
|
10
|
+
|
|
11
|
+
const LOG_FILE = process.env.CURSOR_HOOK_LOG_FILE || ".cursor/hooks/hook.log";
|
|
12
|
+
const DEBUG = process.env.CURSOR_HOOK_DEBUG === "1";
|
|
13
|
+
|
|
14
|
+
function log(line) {
|
|
15
|
+
const ts = new Date().toISOString();
|
|
16
|
+
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
17
|
+
fs.appendFileSync(LOG_FILE, `[${ts}] ${line}\n`, "utf8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readStdin() {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let data = "";
|
|
23
|
+
process.stdin.setEncoding("utf8");
|
|
24
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
25
|
+
process.stdin.on("end", () => resolve(data));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeJson(obj) {
|
|
30
|
+
try { return JSON.stringify(obj, null, 2); } catch { return String(obj); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readTranscriptFile(transcriptPath) {
|
|
34
|
+
if (!transcriptPath) return "";
|
|
35
|
+
try {
|
|
36
|
+
return fs.readFileSync(transcriptPath, "utf8");
|
|
37
|
+
} catch (e) {
|
|
38
|
+
log(`Transcript read error: ${e?.message || e}`);
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
log("Hook start");
|
|
45
|
+
|
|
46
|
+
const enabled = process.env.CURSOR_EMAIL_ON_FINISH === "1";
|
|
47
|
+
if (!enabled) {
|
|
48
|
+
log("CURSOR_EMAIL_ON_FINISH!=1 -> exit");
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const raw = await readStdin();
|
|
53
|
+
log(`stdin bytes=${Buffer.byteLength(raw || "", "utf8")}`);
|
|
54
|
+
|
|
55
|
+
let payload = {};
|
|
56
|
+
try {
|
|
57
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
58
|
+
} catch (e) {
|
|
59
|
+
log(`JSON parse error: ${e?.message || e}`);
|
|
60
|
+
if (DEBUG) log(`RAW:\n${raw}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (DEBUG) log(`PAYLOAD:\n${safeJson(payload)}`);
|
|
65
|
+
|
|
66
|
+
const to = process.env.CURSOR_EMAIL_TO;
|
|
67
|
+
const from = process.env.RESEND_FROM;
|
|
68
|
+
const replyTo = process.env.RESEND_REPLY_TO;
|
|
69
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
70
|
+
|
|
71
|
+
if (!to || !from || !apiKey) {
|
|
72
|
+
log("Missing env vars: CURSOR_EMAIL_TO / RESEND_FROM / RESEND_API_KEY");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseSubject = process.env.CURSOR_EMAIL_SUBJECT ?? "Cursor agent finished";
|
|
77
|
+
const conversationId = payload?.conversation_id || payload?.conversationId || "";
|
|
78
|
+
const subject = conversationId
|
|
79
|
+
? `${baseSubject} [${conversationId}]`
|
|
80
|
+
: baseSubject;
|
|
81
|
+
const text = extractLatestResponseFromPayload(payload, raw || "(empty hook payload)");
|
|
82
|
+
const transcriptPath = getTranscriptPathFromPayload(payload);
|
|
83
|
+
const transcriptText = readTranscriptFile(transcriptPath);
|
|
84
|
+
const html = beautifyPayloadToEmailHTML(payload, {
|
|
85
|
+
title: "Full transcript",
|
|
86
|
+
transcriptText,
|
|
87
|
+
latestResponse: text,
|
|
88
|
+
transcriptPath,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (process.env.CURSOR_DRY_RUN === "1") {
|
|
92
|
+
log(`DRY_RUN=1 subject="${subject}" to="${to}" from="${from}" body_len=${text.length} html_len=${html.length}`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resend = new Resend(apiKey);
|
|
97
|
+
const result = await resend.emails.send({ from, to, subject, text, html, replyTo });
|
|
98
|
+
|
|
99
|
+
if (result?.error) {
|
|
100
|
+
log(`Resend error: ${safeJson(result.error)}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log(`Sent ok id=${result?.data?.id || "unknown"}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main().catch((e) => {
|
|
108
|
+
log(`Unhandled: ${e?.stack || e}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Resend } from "resend";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(express.json({ limit: "2mb" }));
|
|
10
|
+
|
|
11
|
+
// Map job ID to repository path (customize as needed)
|
|
12
|
+
function jobIdToRepoPath(jobId) {
|
|
13
|
+
// Default to current workspace; extend with a lookup table if needed
|
|
14
|
+
const mapping = {
|
|
15
|
+
// "job123": "/path/to/repo",
|
|
16
|
+
};
|
|
17
|
+
return mapping[jobId] || process.env.DEFAULT_REPO_PATH || process.cwd();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Extract email address from "Name <email>" format
|
|
21
|
+
function extractEmail(from) {
|
|
22
|
+
const match = from.match(/<([^>]+)>/);
|
|
23
|
+
return match ? match[1] : from;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Render CLI output as HTML
|
|
27
|
+
function renderResultHtml(stdout) {
|
|
28
|
+
const escaped = stdout
|
|
29
|
+
.replace(/&/g, "&")
|
|
30
|
+
.replace(/</g, "<")
|
|
31
|
+
.replace(/>/g, ">");
|
|
32
|
+
return `<pre style="font-family:monospace;white-space:pre-wrap;">${escaped}</pre>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripQuotedReply(text = "") {
|
|
36
|
+
// Remove quoted lines and email citation headers (English and Spanish)
|
|
37
|
+
return text
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter((l) => !l.trim().startsWith(">"))
|
|
40
|
+
.join("\n")
|
|
41
|
+
.split(/\n\nOn .*wrote:\n/i)[0] // English: "On ... wrote:"
|
|
42
|
+
.split(/\n\nEl [^,]+,.*escribió:\n/i)[0] // Spanish: "El mié, 4 feb 2026...escribió:"
|
|
43
|
+
.split(/\n\nEl [^,]+,/)[0] // Spanish fallback: cut at "El día,"
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
app.post("/inbound/resend", async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
const dryRun = req.query.dryRun === "true" || process.env.DRY_RUN === "true";
|
|
50
|
+
|
|
51
|
+
if (dryRun) {
|
|
52
|
+
console.log("Payload received:", JSON.stringify(req.body, null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resend inbound event payload includes the parsed email metadata.
|
|
56
|
+
const event = req.body;
|
|
57
|
+
const data = event?.data || {};
|
|
58
|
+
const emailId = data?.email_id;
|
|
59
|
+
|
|
60
|
+
// Fetch full email content via Resend Receiving API
|
|
61
|
+
const resendClient = new Resend(process.env.RESEND_API_KEY);
|
|
62
|
+
|
|
63
|
+
if (dryRun) {
|
|
64
|
+
console.log("Email ID from webhook:", emailId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Use receiving API for inbound emails
|
|
68
|
+
const fullEmail = await resendClient.emails.receiving.get(emailId);
|
|
69
|
+
|
|
70
|
+
if (dryRun) {
|
|
71
|
+
console.log("Full email received");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const subject = data?.subject || "";
|
|
75
|
+
const to = (data?.to && data.to[0]) || "";
|
|
76
|
+
const from = data?.from || "";
|
|
77
|
+
|
|
78
|
+
// correlation: extract job ID (UUID) from subject like "[dcbd565f-6540-4794-b5e9-6e514346faa4]"
|
|
79
|
+
const m = subject.match(/\[([a-f0-9-]{36})\]/i);
|
|
80
|
+
if (!m) return res.status(200).json({ ok: true, ignored: "no job id" });
|
|
81
|
+
const jobId = m[1];
|
|
82
|
+
|
|
83
|
+
// Use text from Receiving API response
|
|
84
|
+
const replyText = stripQuotedReply(fullEmail.data?.text || "");
|
|
85
|
+
|
|
86
|
+
const repoPath = jobIdToRepoPath(jobId);
|
|
87
|
+
const prompt = replyText;
|
|
88
|
+
|
|
89
|
+
const cursorArgs = ["agent", "--resume", jobId, "--print", "--output-format", "json", prompt];
|
|
90
|
+
const cursorCommand = {
|
|
91
|
+
command: "cursor",
|
|
92
|
+
args: cursorArgs,
|
|
93
|
+
cwd: repoPath,
|
|
94
|
+
full: `cursor agent --resume ${jobId} --print --output-format json ${JSON.stringify(prompt)}`,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Always print the command
|
|
98
|
+
console.log("Cursor command:", cursorCommand.full);
|
|
99
|
+
|
|
100
|
+
if (dryRun) {
|
|
101
|
+
return res.json({
|
|
102
|
+
ok: true,
|
|
103
|
+
dryRun: true,
|
|
104
|
+
parsed: {
|
|
105
|
+
jobId,
|
|
106
|
+
from,
|
|
107
|
+
to,
|
|
108
|
+
subject,
|
|
109
|
+
replyText,
|
|
110
|
+
repoPath,
|
|
111
|
+
},
|
|
112
|
+
cursorCommand,
|
|
113
|
+
email: {
|
|
114
|
+
from: process.env.RESEND_FROM,
|
|
115
|
+
to: extractEmail(from),
|
|
116
|
+
subject,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run Cursor CLI
|
|
122
|
+
const { stdout } = await execFileAsync(
|
|
123
|
+
cursorCommand.command,
|
|
124
|
+
cursorCommand.args,
|
|
125
|
+
{ cwd: cursorCommand.cwd, timeout: 10 * 60 * 1000 }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await resendClient.emails.send({
|
|
129
|
+
from: process.env.RESEND_FROM,
|
|
130
|
+
to: extractEmail(from),
|
|
131
|
+
subject,
|
|
132
|
+
html: renderResultHtml(stdout),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
res.json({ ok: true });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.error("Webhook error:", e);
|
|
138
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Health check endpoint
|
|
143
|
+
app.get("/health", (req, res) => {
|
|
144
|
+
res.json({ ok: true, timestamp: new Date().toISOString() });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const port = process.env.WEBHOOK_PORT || 8787;
|
|
148
|
+
app.listen(port, () => {
|
|
149
|
+
console.log(`Webhook server listening on port ${port}`);
|
|
150
|
+
});
|