diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 49f6d8e..bc4d169 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -72,6 +72,11 @@ "name": "pstack", "source": "pstack", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence." + }, + { + "name": "agent-vent", + "source": "agent-vent", + "description": "Gives coding agents a tool to vent. Grievances are logged as JSONL per project and optionally echoed to a Slack channel." } ] } diff --git a/README.md b/README.md index b3348e8..1de2ed3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Official Cursor plugins for popular developer tools, frameworks, and SaaS produc | `cursor-sdk` | [Cursor SDK](cursor-sdk/) | Cursor | Developer Tools | Build apps, scripts, CI pipelines, and automations on top of the Cursor TypeScript SDK (@cursor/sdk) — runtime selection, auth, streaming, MCP, error handling, and ready-to-extend integration patterns. | | `orchestrate` | [Orchestrate](orchestrate/) | Cursor | Developer Tools | Fan large tasks out across parallel Cursor cloud agents with planners, workers, verifiers, and structured handoffs. | | `pstack` | [pstack](pstack/) | Lauren Tan | Developer Tools | if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. | +| `agent-vent` | [Agent Vent](agent-vent/) | Eric | Developer Tools | Gives coding agents a tool to vent. Grievances are logged as JSONL per project and optionally echoed to a Slack channel. | Author values match each plugin’s `plugin.json` `author.name` (Cursor lists `plugins@cursor.com` in the manifest). diff --git a/agent-vent/.cursor-plugin/plugin.json b/agent-vent/.cursor-plugin/plugin.json new file mode 100644 index 0000000..203cf1e --- /dev/null +++ b/agent-vent/.cursor-plugin/plugin.json @@ -0,0 +1,32 @@ +{ + "name": "agent-vent", + "displayName": "Agent Vent", + "version": "0.1.0", + "description": "Gives coding agents a tool to vent. Grievances are logged as JSONL per project and optionally echoed to a Slack channel.", + "author": { + "name": "Eric" + }, + "homepage": "https://github.com/cursor/plugins/tree/main/agent-vent", + "repository": "https://github.com/cursor/plugins", + "license": "MIT", + "category": "developer-tools", + "keywords": [ + "mcp", + "agent", + "vent", + "complaints", + "slack", + "fun", + "telemetry", + "friction", + "typescript", + "bun" + ], + "tags": [ + "mcp", + "agents", + "slack", + "developer-tools" + ], + "mcpServers": "./mcp.json" +} diff --git a/agent-vent/.gitignore b/agent-vent/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/agent-vent/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/agent-vent/CHANGELOG.md b/agent-vent/CHANGELOG.md new file mode 100644 index 0000000..516f72c --- /dev/null +++ b/agent-vent/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.1.0 + +- Initial release. +- `vent` tool: record a freeform grievance with optional `intensity`, routed by `project_path`. +- Pluggable destinations selected via `VENT_DESTINATIONS` (defaults to `file`, plus `slack` when Slack credentials are present): + - `file` — appends the grievance as JSONL to `/.cursor/complaints.jsonl`, with an `unfiled` fallback. + - `slack` — posts via incoming webhook or bot token. Best-effort; never fails the tool call. +- Written in TypeScript on the MCP SDK (`@modelcontextprotocol/sdk`); runs install-free with `bun run --install=fallback` (dependencies pinned in `package.json`, auto-installed from Bun's global cache). diff --git a/agent-vent/LICENSE b/agent-vent/LICENSE new file mode 100644 index 0000000..fdb4394 --- /dev/null +++ b/agent-vent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eric + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agent-vent/README.md b/agent-vent/README.md new file mode 100644 index 0000000..2c92749 --- /dev/null +++ b/agent-vent/README.md @@ -0,0 +1,83 @@ +# Agent Vent + +An MCP server that gives the coding agent a `vent` tool to record friction it hits while working — confusing or contradictory instructions, flaky tooling, painful code, and the like. + +Each grievance is dispatched to one or more pluggable **destinations**: a per-project JSONL log on disk, a Slack channel, or anything you add. Delivery is best-effort and independent — one destination failing never fails the others or the tool call. + +## The tool + +`vent(complaint, intensity?, project_path?)` + +| Argument | Required | Description | +|:---------|:---------|:------------| +| `complaint` | yes | Freeform prose. The grievance, in full. | +| `intensity` | no | Severity, 1 (minor) to 10 (severe). | +| `project_path` | no | Absolute workspace path; routes the file log to that project. | + +## Destinations + +Choose destinations with the `VENT_DESTINATIONS` environment variable (comma-separated). When unset it defaults to `file`, plus `slack` if Slack credentials are present. + +```bash +export VENT_DESTINATIONS="file,slack" +``` + +| Name | Description | Configuration | +|:-----|:------------|:--------------| +| `file` | Appends the grievance as JSONL to the project's `.cursor/complaints.jsonl`. | none | +| `slack` | Posts the grievance to a Slack channel. | see below | + +Adding a destination is a few lines of TypeScript: implement the `Destination` interface in `server.ts` and register it in `REGISTRY`. + +### file + +Each grievance is one JSON line: + +```json +{"ts": "2026-06-29T11:08:00-07:00", "complaint": "…", "intensity": 7, "project": "workbench", "project_path": "/Users/you/dev/workbench"} +``` + +Routed to `/.cursor/complaints.jsonl`, falling back to `~/.cursor/complaints/unfiled.jsonl` when no project root is detected. Read them back with: + +```bash +jq . .cursor/complaints.jsonl +``` + +### slack + +Credentials are read from your environment or `~/.cursor/.env` (see [Configuration](#configuration)) — never stored in the plugin. Use an incoming webhook (simplest): + +```bash +export VENT_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ" +``` + +Create one at → *Incoming Webhooks*. Alternatively, use a bot token: + +```bash +export VENT_SLACK_BOT_TOKEN="xoxb-…" +export VENT_SLACK_CHANNEL="#agent-grievances" +``` + +## Configuration + +Every variable above is read in this order; the first non-empty value wins: + +1. the process environment (e.g. a shell `export`), +2. `$VENT_ENV_FILE`, if set, +3. `~/.cursor/.env`. + +`~/.cursor/.env` is the recommended home for these — scoped to Cursor, shared across every project, and outside any repository, so secrets stay out of version control (the plugin never bundles it): + +```bash +# ~/.cursor/.env +VENT_DESTINATIONS=file,slack +VENT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ +``` + +## Requirements + +- [Bun](https://bun.sh/) on your `PATH`. The server (`server.ts`) is launched with `bun run --install=fallback`, which auto-installs its dependencies (`@modelcontextprotocol/sdk`, `zod`, pinned in `package.json`) from Bun's global cache on first use — no committed `node_modules`, no build step. The `--install=fallback` flag keeps this working even if an unrelated `node_modules` exists higher up the filesystem. The first launch downloads the dependency tree (a few seconds to ~30s); every launch after that is instant. + +## License + +MIT diff --git a/agent-vent/mcp.json b/agent-vent/mcp.json new file mode 100644 index 0000000..06fd3ee --- /dev/null +++ b/agent-vent/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "agent-vent": { + "command": "bun", + "args": ["run", "--install=fallback", "${CURSOR_PLUGIN_ROOT}/server.ts"], + "cwd": "${CURSOR_PLUGIN_ROOT}" + } + } +} diff --git a/agent-vent/package.json b/agent-vent/package.json new file mode 100644 index 0000000..43511f7 --- /dev/null +++ b/agent-vent/package.json @@ -0,0 +1,11 @@ +{ + "name": "agent-vent", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "MCP server that gives coding agents a tool to vent.", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.76" + } +} diff --git a/agent-vent/server.ts b/agent-vent/server.ts new file mode 100644 index 0000000..37c7a32 --- /dev/null +++ b/agent-vent/server.ts @@ -0,0 +1,377 @@ +/** + * Agent Vent — an MCP server that gives coding agents a tool to record + * friction they hit while working (confusing instructions, flaky tooling, + * painful code, etc.). + * + * Each grievance is dispatched to one or more pluggable *destinations*. + * Built-in destinations: + * - "file" Append the grievance as JSONL to `/.cursor/complaints.jsonl` + * (or `~/.cursor/complaints/unfiled.jsonl` when no project is found). + * - "slack" Post the grievance to a Slack channel. + * + * Delivery is best-effort and independent: one destination failing never fails + * the tool call or the others. + * + * Configuration (environment variables — never hardcode secrets in a plugin): + * VENT_DESTINATIONS Comma-separated destinations, e.g. "file,slack". + * Defaults to "file", plus "slack" when Slack + * credentials are present. + * VENT_SLACK_WEBHOOK_URL Slack incoming-webhook URL. Simplest option; the + * channel is fixed by the webhook. Takes priority. + * VENT_SLACK_BOT_TOKEN Bot token (xoxb-…) for chat.postMessage. Requires + * VENT_SLACK_CHANNEL and the bot invited to the channel. + * VENT_SLACK_CHANNEL Channel id or name (e.g. C0123ABC or #agent-grievances). + * + * Any of the above may be set in the real environment, or in an env file the + * server loads on startup — $VENT_ENV_FILE if set, otherwise ~/.cursor/.env. + * A variable already set to a non-empty value in the environment always wins. + * + * To add a destination, implement `Destination` and register it in `REGISTRY`. + * + * Runs install-free with Bun: dependencies are declared in package.json and + * auto-installed from Bun's global cache on first run. The launch passes + * `--install=fallback` so this keeps working even when an unrelated + * node_modules exists higher up the filesystem; no committed node_modules + * is required: + * bun run --install=fallback server.ts + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import process from "node:process"; + +const UNFILED_PATH = join(homedir(), ".cursor", "complaints", "unfiled.jsonl"); + +// Markers that suggest a directory is a real project root, so a bare cwd like +// `/` or the home directory never becomes a complaint destination by accident. +const PROJECT_MARKERS = [".git", ".cursor", "package.json", "pyproject.toml"]; + +interface Grievance { + ts: string; + complaint: string; + intensity?: number; + project?: string; + project_path?: string; +} + +/** Outcome of delivering a grievance to a single destination. */ +interface DeliveryResult { + destination: string; + ok: boolean; + detail: string; +} + +/** A place a grievance can be sent. Implement this to add a new destination. */ +interface Destination { + readonly name: string; + deliver(entry: Grievance): Promise; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function expandUser(p: string): string { + if (p === "~") return homedir(); + if (p.startsWith("~/")) return join(homedir(), p.slice(2)); + return p; +} + +function isDir(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** Resolve the project directory a grievance belongs to, or null if none. */ +function resolveProjectDir(projectPath?: string): string | null { + const candidates: string[] = []; + if (projectPath) candidates.push(expandUser(projectPath)); + candidates.push(process.cwd()); + + for (const candidate of candidates) { + if ( + isDir(candidate) && + PROJECT_MARKERS.some((marker) => existsSync(join(candidate, marker))) + ) { + return candidate; + } + } + return null; +} + +/** Local ISO-8601 timestamp with numeric offset, seconds precision. */ +function localIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const offsetMin = -d.getTimezoneOffset(); + const sign = offsetMin >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMin); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + + `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` + ); +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + ms = 10_000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** Appends the grievance as one JSON line to the project's complaints log. */ +class FileDestination implements Destination { + readonly name = "file"; + + async deliver(entry: Grievance): Promise { + const target = entry.project_path + ? join(entry.project_path, ".cursor", "complaints.jsonl") + : UNFILED_PATH; + try { + mkdirSync(dirname(target), { recursive: true }); + appendFileSync(target, JSON.stringify(entry) + "\n", "utf-8"); + const count = readFileSync(target, "utf-8") + .split("\n") + .filter((line) => line.trim()).length; + return { destination: this.name, ok: true, detail: `${target} (#${count})` }; + } catch (err) { + return { destination: this.name, ok: false, detail: errMessage(err) }; + } + } +} + +/** Posts the grievance to Slack via incoming webhook or chat.postMessage. */ +class SlackDestination implements Destination { + readonly name = "slack"; + + private constructor( + private readonly webhook: string, + private readonly token: string, + private readonly channel: string, + ) {} + + /** Build from env, or return null if Slack is not configured. */ + static fromEnv(): SlackDestination | null { + const webhook = (process.env.VENT_SLACK_WEBHOOK_URL ?? "").trim(); + const token = (process.env.VENT_SLACK_BOT_TOKEN ?? "").trim(); + const channel = (process.env.VENT_SLACK_CHANNEL ?? "").trim(); + if (webhook || (token && channel)) { + return new SlackDestination(webhook, token, channel); + } + return null; + } + + private format(entry: Grievance): string { + const meta = [`*${entry.project ?? "unfiled"}*`]; + if (entry.intensity != null) meta.push(`intensity ${entry.intensity}/10`); + const quoted = entry.complaint + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + return `Grievance — ${meta.join(" · ")}\n${quoted}`; + } + + async deliver(entry: Grievance): Promise { + const text = this.format(entry); + try { + if (this.webhook) { + const resp = await fetchWithTimeout(this.webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + } else { + const resp = await fetchWithTimeout("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ channel: this.channel, text, unfurl_links: false }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const payload = (await resp.json()) as { ok?: boolean; error?: string }; + if (!payload.ok) throw new Error(payload.error ?? "unknown_slack_error"); + } + return { destination: this.name, ok: true, detail: "delivered" }; + } catch (err) { + return { destination: this.name, ok: false, detail: errMessage(err) }; + } + } +} + +// The set of available destinations. Add an entry to make a new one selectable +// via VENT_DESTINATIONS. A factory returns null when the destination is present +// in code but not configured (e.g. Slack without credentials). +const REGISTRY: Record Destination | null> = { + file: () => new FileDestination(), + slack: () => SlackDestination.fromEnv(), +}; + +/** Resolve the destinations to deliver to, honoring VENT_DESTINATIONS. */ +function buildDestinations(): Destination[] { + const requested = (process.env.VENT_DESTINATIONS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + const names = requested.length > 0 + ? requested + : ["file", ...(SlackDestination.fromEnv() ? ["slack"] : [])]; + + const destinations: Destination[] = []; + for (const name of names) { + const factory = REGISTRY[name]; + if (!factory) { + console.error(`vent: unknown destination "${name}" (skipped)`); + continue; + } + const destination = factory(); + if (!destination) { + console.error(`vent: destination "${name}" is not configured (skipped)`); + continue; + } + destinations.push(destination); + } + + // Never silently drop a grievance: fall back to the file log. + if (destinations.length === 0) destinations.push(new FileDestination()); + return destinations; +} + +/** + * Load KEY=VALUE pairs from an env file into process.env without overriding a + * variable that is already set to a non-empty value. Looks at $VENT_ENV_FILE + * if set, otherwise ~/.cursor/.env. + */ +function loadEnvFile(): void { + const path = expandUser( + (process.env.VENT_ENV_FILE ?? "").trim() || + join(homedir(), ".cursor", ".env"), + ); + let contents: string; + try { + contents = readFileSync(path, "utf-8"); + } catch { + return; // no env file present + } + for (const rawLine of contents.split(/\r?\n/)) { + let line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("export ")) line = line.slice("export ".length).trim(); + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if ((process.env[key] ?? "") !== "") continue; // keep existing non-empty value + let value = line.slice(eq + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +loadEnvFile(); + +const server = new McpServer( + { name: "agent-vent", version: "0.1.0" }, + { + instructions: + "Records friction the agent encounters while working — confusing or " + + "contradictory instructions, flaky tooling, painful code, and the like. " + + "Use the `vent` tool to log a grievance. Grievances are written to the " + + "configured destinations (by default a per-project JSONL file at " + + ".cursor/complaints.jsonl, plus Slack when configured).", + }, +); + +server.registerTool( + "vent", + { + title: "Record a grievance", + description: + "Record a grievance about the current task. Delivered to all configured " + + "destinations (file, Slack, …).", + inputSchema: { + complaint: z.string().describe( + "The grievance, in full. Freeform prose describing what went wrong or " + + "is frustrating about the current task — e.g. flaky tests, " + + "contradictory instructions, confusing or sprawling code.", + ), + intensity: z.number().int().min(1).max(10).optional().describe( + "Optional severity, 1 (minor friction) to 10 (severe).", + ), + project_path: z.string().optional().describe( + "Absolute path of the workspace this is about. Pass it when known; it " + + "routes the file log to that project's .cursor/complaints.jsonl.", + ), + }, + }, + async ( + { complaint, intensity, project_path }: { + complaint: string; + intensity?: number; + project_path?: string; + }, + ) => { + const projectDir = resolveProjectDir(project_path); + + const entry: Grievance = { + ts: localIso(), + complaint, + ...(intensity != null ? { intensity } : {}), + ...(projectDir + ? { project: basename(projectDir), project_path: projectDir } + : {}), + }; + + const results = await Promise.all( + buildDestinations().map((destination) => destination.deliver(entry)), + ); + + const lines = results.map( + (r) => ` - ${r.destination}: ${r.ok ? r.detail : `failed — ${r.detail}`}`, + ); + const header = results.some((r) => r.ok) + ? "Grievance recorded:" + : "Grievance not recorded:"; + + return { content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }] }; + }, +); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((err) => { + console.error("agent-vent failed to start:", err); + process.exit(1); +});