diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..87b0992
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,45 @@
+# AGENTS.md
+
+## Cursor Cloud specific instructions
+
+### Overview
+
+TaskFlow is a Next.js 16 project management SaaS (single app, no monorepo). Tech stack: Next.js 16 + Turbopack, React 19, Tailwind CSS v4, Shadcn UI, PostgreSQL, pnpm.
+
+### Local database setup
+
+A local PostgreSQL 16 instance is used in place of Neon's serverless PostgreSQL. The database is pre-seeded with mock `neon_auth` schema tables, a sample organization, user, project, and tasks. RLS is disabled locally.
+
+- **Start PostgreSQL** (if not running): `sudo pg_ctlcluster 16 main start`
+- **Connection**: `postgresql://taskflow:taskflow@localhost:5432/taskflow`
+- **Schema**: `scripts/01-create-schema.sql` (requires `neon_auth` schema tables created first)
+
+### Auth stub
+
+The `@stack-auth/nextjs` package does not exist on npm. A local stub at `stubs/stack-auth-nextjs/` provides mock `currentUser()` and `useUser()` functions that return a hardcoded dev user (UUID `00000000-0000-0000-0000-000000000002`, org `00000000-0000-0000-0000-000000000001`). The stub uses `react-server` conditional exports to properly separate server/client code.
+
+### DB module (`lib/db.ts`)
+
+Exports two database interfaces:
+- `sql(query, params)` — function-call style, returns `rows` array (used by `lib/queries.ts`)
+- `db` — `pg.Pool` instance with `db.query()` (used by API routes under `app/api/`)
+
+Both use the local PostgreSQL via the `pg` package. The original `@neondatabase/serverless` neon driver is not used locally.
+
+### Common commands
+
+| Task | Command |
+|------|---------|
+| Install deps | `pnpm install` |
+| Dev server | `pnpm dev` (port 3000) |
+| Lint | `pnpm lint` |
+| Build | `pnpm build` |
+| Production | `pnpm start` |
+
+### Gotchas
+
+- The `@neondatabase/serverless` package is listed in `package.json` but not used at runtime locally. The `lib/db.ts` module wraps everything through `pg.Pool`.
+- Some API routes (activity, analytics, members, attachments) import `{ db }` from `@/lib/db` and use `db.query()`. Other code (in `lib/queries.ts`) imports `{ sql }` and calls `sql()` as a function. Both patterns are supported.
+- The ESLint config (`eslint.config.mjs`) uses `@eslint/js` with basic recommended rules. It doesn't include the full `next/core-web-vitals` config due to ESLint 9 compatibility issues.
+- `next.config.mjs` has `typescript: { ignoreBuildErrors: true }`, so TypeScript errors won't block builds.
+- There is no root page (`app/page.tsx`). The app entry point is `/dashboard`.
diff --git a/env-agent-finder/.gitignore b/env-agent-finder/.gitignore
new file mode 100644
index 0000000..aafcb34
--- /dev/null
+++ b/env-agent-finder/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.DS_Store
+*.log
diff --git a/env-agent-finder/README.md b/env-agent-finder/README.md
new file mode 100644
index 0000000..1ae7154
--- /dev/null
+++ b/env-agent-finder/README.md
@@ -0,0 +1,213 @@
+# env-agent-finder
+
+A zero-dependency CLI tool that scans any codebase to discover:
+
+- **Environment variables** — finds every `process.env.*` reference, `.env` file, and maps them to known services
+- **API routes** — detects Next.js App/Pages Router, Express, FastAPI, and Flask endpoints
+- **Service dependencies** — identifies databases, auth providers, storage, payments, AI, email, and more
+
+Built for AI agents and developers who need to quickly understand what a project needs to run.
+
+## Quick Start
+
+```bash
+# Scan current directory
+node src/cli.js
+
+# Scan a specific project
+node src/cli.js --target /path/to/project
+
+# Show env values (masked for security)
+node src/cli.js --target ./my-app --show-values
+
+# Show full unmasked values
+node src/cli.js --target ./my-app --unmask
+
+# Output as Markdown
+node src/cli.js --target ./my-app --format markdown --output report.md
+
+# Output as JSON (with values)
+node src/cli.js --format json --show-values
+```
+
+## Installation
+
+No dependencies required — just clone and run:
+
+```bash
+git clone https://github.com/DealPatrol/env-agent-finder.git
+cd env-agent-finder
+node src/cli.js --target /path/to/your/project
+```
+
+Or use npx (after publishing):
+
+```bash
+npx env-agent-finder --target ./my-project
+```
+
+## Output Modes
+
+### Default — summary table
+```
+🔑 ENVIRONMENT VARIABLES (5 found)
+ Variable Service Refs Status
+ DATABASE_URL Database (PostgreSQL) 3 ✅ Set
+ BLOB_READ_WRITE_TOKEN Vercel Blob Storage 1 ⚪ Optional
+ STACK_PROJECT_ID Stack Auth 1 ❌ Missing
+
+ 💡 Use --show-values to see values, or --unmask for full values.
+```
+
+### `--show-values` — masked values with source
+```
+ ✅ DATABASE_URL
+ Service: Database (PostgreSQL/MySQL)
+ Refs: 3 reference(s) in code
+ Value: post••••••••••••••••••••••••••••••flow
+ Source: .env.local
+
+ ❌ BLOB_READ_WRITE_TOKEN
+ Service: Vercel Blob Storage
+ Refs: 1 reference(s) in code
+ Value: (not set)
+```
+
+### `--unmask` — full raw values
+```
+ ✅ DATABASE_URL
+ Service: Database (PostgreSQL/MySQL)
+ Refs: 3 reference(s) in code
+ Value: postgresql://user:pass@host:5432/mydb
+ Source: .env.local
+
+📄 CURRENT .env VALUES
+ DATABASE_URL=postgresql://user:pass@host:5432/mydb
+ STACK_PROJECT_ID=proj_abc123
+```
+
+## Options
+
+| Flag | Description |
+|------|-------------|
+| `--target
` | Directory to scan (default: current directory) |
+| `--show-values`, `-v` | Show env variable values (masked by default) |
+| `--unmask` | Show full unmasked values (implies `--show-values`) |
+| `--format ` | Output format: `terminal`, `json`, `markdown` (default: `terminal`) |
+| `--output ` | Write output to file instead of stdout |
+| `-h`, `--help` | Show help message |
+
+## What It Detects
+
+### Environment Variables
+- `process.env.VAR_NAME` (Node.js)
+- `os.environ` / `os.getenv()` (Python)
+- `import.meta.env.VAR_NAME` (Vite)
+- `.env`, `.env.local`, `.env.example` files
+- 50+ well-known service variable mappings
+
+### API Routes
+- Next.js App Router (`app/api/**/route.ts`)
+- Next.js Pages Router (`pages/api/**/*.ts`)
+- Express / Fastify (`app.get()`, `router.post()`, etc.)
+- FastAPI / Flask (`@app.get()`, `@router.post()`, etc.)
+
+### Services (30+ supported)
+| Category | Services |
+|----------|----------|
+| Database | PostgreSQL, MySQL, MongoDB, Supabase, Firebase |
+| Auth | NextAuth, Clerk, Stack Auth, Auth.js |
+| Storage | Vercel Blob, AWS S3, Cloudinary |
+| Payments | Stripe |
+| AI | OpenAI, Anthropic |
+| Email | SendGrid, Resend, SMTP |
+| Monitoring | Sentry |
+| Cache | Redis, Upstash |
+| Messaging | Twilio |
+
+## Setup Mode — Auto-Provision Your `.env`
+
+The killer feature: point it at any project and it **builds your `.env.local` for you**.
+
+### `--setup` (Interactive)
+
+Scans the project, auto-generates secrets and DB URLs, then walks you through each external service with step-by-step signup instructions and prompts you to paste your keys:
+
+```bash
+node src/cli.js --target ./my-project --setup
+```
+
+```
+════════════════════════════════════════════════════════════
+ ENV AGENT FINDER — Setup Mode
+════════════════════════════════════════════════════════════
+
+ Found 8 environment variable(s) needed.
+ Detected 4 service(s): PostgreSQL, Stripe, OpenAI, Auth.js
+
+⚡ AUTO-GENERATING values...
+────────────────────────────────────────
+ ✅ DATABASE_URL = postgresql://postgres:postgres@localhost:5432/app_dev
+ ✅ NEXTAUTH_SECRET = a8Kx9mP2qR7...
+ ✅ NEXTAUTH_URL = http://localhost:PORT
+
+🔑 STRIPE
+────────────────────────────────────────
+ How to get your keys:
+ 1. Go to https://dashboard.stripe.com/register and create an account
+ 2. Go to https://dashboard.stripe.com/apikeys
+ 3. Copy your Publishable key → STRIPE_PUBLISHABLE_KEY
+ 4. Copy your Secret key → STRIPE_SECRET_KEY
+
+ Enter STRIPE_SECRET_KEY (or press Enter to skip): sk_test_abc123
+ ✅ STRIPE_SECRET_KEY saved
+
+🔑 OPENAI
+────────────────────────────────────────
+ How to get your keys:
+ 1. Go to https://platform.openai.com/api-keys
+ 2. Click 'Create new secret key'
+
+ Enter OPENAI_API_KEY (or press Enter to skip): sk-proj-abc123
+ ✅ OPENAI_API_KEY saved
+
+════════════════════════════════════════════════════════════
+ SETUP COMPLETE — Written to: ./my-project/.env.local
+════════════════════════════════════════════════════════════
+```
+
+### `--auto` (Non-Interactive)
+
+Auto-generates everything it can (secrets, database URLs) without prompting. Perfect for CI or AI agents:
+
+```bash
+node src/cli.js --target ./my-project --auto
+```
+
+### What Gets Auto-Generated vs What You Need to Provide
+
+| Type | Example Variables | How It's Handled |
+|------|-------------------|------------------|
+| **Secrets** | `NEXTAUTH_SECRET`, `JWT_SECRET`, `AUTH_SECRET` | Auto-generated (random 48-char string) |
+| **Database URLs** | `DATABASE_URL`, `POSTGRES_URL`, `MONGODB_URI` | Auto-generated (local dev defaults) |
+| **App URLs** | `NEXTAUTH_URL`, `APP_URL` | Auto-generated (localhost dev URL) |
+| **API Keys** | `STRIPE_SECRET_KEY`, `OPENAI_API_KEY` | Interactive prompt with signup instructions |
+| **OAuth Creds** | `GOOGLE_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Interactive prompt with setup guide |
+| **Existing Values** | Any var already in `.env` | Kept as-is (never overwritten) |
+
+### Supported Services (with signup guides)
+
+Stripe, OpenAI, Anthropic, Clerk, Stack Auth, Supabase, Firebase, Vercel Blob, AWS S3, SendGrid, Resend, Sentry, Google OAuth, GitHub OAuth, Twilio, Cloudinary, Upstash Redis
+
+## Use Cases
+
+- **New project setup**: Clone a repo, run `--setup`, get a working `.env.local` in 60 seconds
+- **AI Agents**: Run `--auto` to provision env vars without human interaction
+- **Onboarding**: New developer? Run `--setup` and follow the guided prompts
+- **CI/CD**: Validate that all required secrets are configured with scan mode
+- **Documentation**: Auto-generate env var docs with `--format markdown`
+- **Security Audits**: Find all external service dependencies and API key usage
+
+## License
+
+MIT
diff --git a/env-agent-finder/package.json b/env-agent-finder/package.json
new file mode 100644
index 0000000..fc9cb1e
--- /dev/null
+++ b/env-agent-finder/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "env-agent-finder",
+ "version": "2.0.0",
+ "description": "CLI + Web tool that scans projects, discovers env vars, auto-generates secrets, and grabs API keys from service dashboards via browser automation.",
+ "main": "src/index.js",
+ "bin": {
+ "env-agent-finder": "src/cli.js"
+ },
+ "scripts": {
+ "start": "node src/server.js",
+ "app": "node src/server.js",
+ "scan": "node src/cli.js",
+ "grab": "node src/cli.js --grab",
+ "setup": "node src/cli.js --setup",
+ "generate-key": "node src/cli.js --generate-key",
+ "test": "node src/cli.js --target ."
+ },
+ "optionalDependencies": {
+ "puppeteer": "^24.0.0"
+ },
+ "keywords": [
+ "env",
+ "environment",
+ "api",
+ "scanner",
+ "agent",
+ "devops",
+ "cli",
+ "browser-automation",
+ "api-keys"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/env-agent-finder/public/index.html b/env-agent-finder/public/index.html
new file mode 100644
index 0000000..134650d
--- /dev/null
+++ b/env-agent-finder/public/index.html
@@ -0,0 +1,690 @@
+
+
+
+
+
+env-agent-finder — Discover env vars for any project
+
+
+
+
+
+
+
+ env-agent-finder
+ Drop your project files or paste a GitHub URL to discover every env variable, API route, and service dependency.
+
+
+
+
+
+
+
+
+
+
+
📂
+
Drop your project files here
+
or click to browse
+
package.json, .env, .env.local, .env.example — or drag an entire folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Fetches package.json and .env.example from the repo's default branch via the GitHub API.
+
+
+
+
+
Analyzing project...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/env-agent-finder/src/app.html b/env-agent-finder/src/app.html
new file mode 100644
index 0000000..8905306
--- /dev/null
+++ b/env-agent-finder/src/app.html
@@ -0,0 +1,738 @@
+
+
+
+
+
+env-agent-finder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scanning project...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/env-agent-finder/src/cli.js b/env-agent-finder/src/cli.js
new file mode 100755
index 0000000..911989d
--- /dev/null
+++ b/env-agent-finder/src/cli.js
@@ -0,0 +1,266 @@
+#!/usr/bin/env node
+
+const path = require("path");
+const fs = require("fs");
+const { scanProject } = require("./index");
+const { renderReport } = require("./reporter");
+const { provisionEnv } = require("./provisioner");
+const { isLicensed, activateLicense, deactivateLicense, getLicenseInfo, generateLicenseKey } = require("./license");
+
+const args = process.argv.slice(2);
+
+let targetDir = process.cwd();
+let outputFormat = "terminal";
+let outputFile = null;
+let showValues = false;
+let unmask = false;
+let setupMode = false;
+let autoOnly = false;
+let grabMode = false;
+let licenseCmd = null;
+let licenseKey = null;
+
+for (let i = 0; i < args.length; i++) {
+ if (args[i] === "--target" && args[i + 1]) {
+ targetDir = path.resolve(args[i + 1]); i++;
+ } else if (args[i] === "--format" && args[i + 1]) {
+ outputFormat = args[i + 1]; i++;
+ } else if (args[i] === "--output" && args[i + 1]) {
+ outputFile = path.resolve(args[i + 1]); i++;
+ } else if (args[i] === "--show-values" || args[i] === "-v") {
+ showValues = true;
+ } else if (args[i] === "--unmask") {
+ showValues = true; unmask = true;
+ } else if (args[i] === "--setup" || args[i] === "-s") {
+ setupMode = true;
+ } else if (args[i] === "--auto") {
+ setupMode = true; autoOnly = true;
+ } else if (args[i] === "--grab" || args[i] === "-g") {
+ grabMode = true;
+ } else if (args[i] === "--activate" && args[i + 1]) {
+ licenseCmd = "activate"; licenseKey = args[i + 1]; i++;
+ } else if (args[i] === "--deactivate") {
+ licenseCmd = "deactivate";
+ } else if (args[i] === "--license") {
+ licenseCmd = "info";
+ } else if (args[i] === "--generate-key") {
+ licenseCmd = "generate";
+ } else if (args[i] === "--help" || args[i] === "-h") {
+ console.log(`
+env-agent-finder — Scan, setup, and grab env vars for any project.
+
+SCAN MODE (default — free):
+ --target Directory to scan (default: current directory)
+ --format Output: terminal, json, markdown
+ --output Write to file
+ --show-values, -v Show values (masked)
+ --unmask Show full values
+
+SETUP MODE (free):
+ --setup, -s Interactive setup + auto-generate secrets
+ --auto Non-interactive auto-generate only
+
+GRAB MODE (Pro — requires license):
+ --grab, -g Open browser, log into services, grab API keys automatically
+ Scans project → identifies services → opens each dashboard →
+ extracts or guides you through getting each API key
+
+LICENSE:
+ --activate Activate a license key
+ --deactivate Remove license
+ --license Show license info
+
+Examples:
+ env-agent-finder --target ./my-project # Free scan
+ env-agent-finder --target ./my-project --setup # Free interactive setup
+ env-agent-finder --target ./my-project --grab # Pro: browser grab
+ env-agent-finder --activate EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX
+`);
+ process.exit(0);
+ } else if (!args[i].startsWith("--")) {
+ targetDir = path.resolve(args[i]);
+ }
+}
+
+async function handleLicense() {
+ if (licenseCmd === "activate") {
+ const result = activateLicense(licenseKey);
+ if (result.success) {
+ console.log("\n ✅ License activated! You now have access to --grab mode.\n");
+ } else {
+ console.log(`\n ❌ ${result.error}\n`);
+ }
+ process.exit(0);
+ }
+
+ if (licenseCmd === "deactivate") {
+ deactivateLicense();
+ console.log("\n ✅ License deactivated.\n");
+ process.exit(0);
+ }
+
+ if (licenseCmd === "info") {
+ const info = getLicenseInfo();
+ if (info) {
+ console.log(`\n 🔑 License: ${info.key}`);
+ console.log(` 📅 Activated: ${info.activated}\n`);
+ } else {
+ console.log("\n No license activated. Use --activate to activate.\n");
+ }
+ process.exit(0);
+ }
+
+ if (licenseCmd === "generate") {
+ const key = generateLicenseKey();
+ console.log(`\n 🔑 Generated license key: ${key}\n`);
+ console.log(" Give this key to your customer. They activate it with:");
+ console.log(` env-agent-finder --activate ${key}\n`);
+ process.exit(0);
+ }
+}
+
+async function runGrab() {
+ if (!isLicensed()) {
+ console.log("");
+ console.log(" ═══════════════════════════════════════════════════");
+ console.log(" ⚡ GRAB MODE requires a Pro license");
+ console.log(" ═══════════════════════════════════════════════════");
+ console.log("");
+ console.log(" Grab mode opens a real browser, logs into each service,");
+ console.log(" and extracts API keys directly from the dashboards.");
+ console.log("");
+ console.log(" 🛒 Get a license at: https://your-store-url.com");
+ console.log("");
+ console.log(" Already have a key? Activate it:");
+ console.log(" env-agent-finder --activate EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX");
+ console.log("");
+ process.exit(1);
+ }
+
+ const { findGrabbersForServices } = require("./grabbers");
+ const { closeBrowser } = require("./grabbers/browser");
+
+ console.log(`\n🔍 Scanning: ${targetDir}\n`);
+ const report = await scanProject(targetDir);
+
+ const missingVars = report.envVars.filter((v) => !v.hasValue && v.required !== false);
+ const neededGrabbers = findGrabbersForServices(report.services, missingVars);
+
+ if (neededGrabbers.length === 0) {
+ console.log(" ✅ No missing env vars that require browser automation.");
+ console.log(" All required variables are already set or can be auto-generated.");
+ console.log(" Run --setup to auto-fill the rest.\n");
+ return;
+ }
+
+ console.log("═".repeat(60));
+ console.log(" ENV AGENT FINDER — Grab Mode (Pro)");
+ console.log("═".repeat(60));
+ console.log("");
+ console.log(` Found ${missingVars.length} missing variable(s) across ${neededGrabbers.length} service(s):`);
+ for (const { grabber, relevantVars } of neededGrabbers) {
+ console.log(` • ${grabber.name}: ${relevantVars.join(", ")}`);
+ }
+ console.log("");
+ console.log(" A browser will open for each service. Log in, and the tool");
+ console.log(" will navigate to the right page and help you grab the keys.");
+ console.log("");
+
+ const allGrabbed = {};
+
+ for (const { grabber } of neededGrabbers) {
+ console.log(`\n${"─".repeat(50)}`);
+ console.log(` 🔌 ${grabber.name.toUpperCase()}`);
+ console.log(`${"─".repeat(50)}`);
+
+ try {
+ const grabbed = await grabber.grab();
+ Object.assign(allGrabbed, grabbed);
+ } catch (err) {
+ console.log(` ❌ Error with ${grabber.name}: ${err.message}`);
+ }
+ }
+
+ await closeBrowser();
+
+ // Merge grabbed values with existing
+ const existingEnv = {};
+ for (const v of report.envVars) {
+ if (v.value) existingEnv[v.name] = v.value;
+ }
+
+ const finalValues = { ...existingEnv, ...allGrabbed };
+
+ // Run the provisioner with grabbed values pre-filled
+ console.log("");
+ console.log("═".repeat(60));
+ console.log(" WRITING .env.local");
+ console.log("═".repeat(60));
+
+ // Inject grabbed values into the report
+ for (const v of report.envVars) {
+ if (allGrabbed[v.name]) {
+ v.value = allGrabbed[v.name];
+ v.hasValue = true;
+ }
+ }
+
+ await provisionEnv(targetDir, report, { interactive: false, autoOnly: true });
+
+ // Overwrite with the grabbed values
+ const envPath = path.join(targetDir, ".env.local");
+ if (fs.existsSync(envPath)) {
+ let content = fs.readFileSync(envPath, "utf-8");
+ for (const [key, val] of Object.entries(allGrabbed)) {
+ const regex = new RegExp(`^${key}=.*$`, "m");
+ if (regex.test(content)) {
+ content = content.replace(regex, `${key}=${val}`);
+ } else {
+ content += `\n${key}=${val}`;
+ }
+ }
+ fs.writeFileSync(envPath, content, "utf-8");
+ }
+
+ const grabbedCount = Object.keys(allGrabbed).length;
+ console.log("");
+ console.log(` 🎯 Grabbed ${grabbedCount} value(s) from browser sessions.`);
+ console.log(` 📄 Saved to: ${envPath}`);
+ console.log("");
+}
+
+async function main() {
+ if (licenseCmd) {
+ await handleLicense();
+ return;
+ }
+
+ if (grabMode) {
+ await runGrab();
+ return;
+ }
+
+ console.log(`\n🔍 Scanning: ${targetDir}\n`);
+
+ try {
+ const report = await scanProject(targetDir);
+
+ if (setupMode) {
+ await provisionEnv(targetDir, report, { interactive: !autoOnly, autoOnly });
+ } else {
+ const output = renderReport(report, outputFormat, { showValues, unmask });
+
+ if (outputFile) {
+ fs.writeFileSync(outputFile, output, "utf-8");
+ console.log(`\n✅ Report written to: ${outputFile}`);
+ } else {
+ console.log(output);
+ }
+ }
+ } catch (err) {
+ console.error(`\n❌ Error scanning project: ${err.message}`);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/env-agent-finder/src/grabbers/anthropic.js b/env-agent-finder/src/grabbers/anthropic.js
new file mode 100644
index 0000000..8e89bb0
--- /dev/null
+++ b/env-agent-finder/src/grabbers/anthropic.js
@@ -0,0 +1,35 @@
+const { openPage, waitForUserLogin, askUser } = require("./browser");
+
+module.exports = {
+ name: "Anthropic",
+ vars: ["ANTHROPIC_API_KEY"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening Anthropic console...");
+ const page = await openPage("https://console.anthropic.com/login");
+
+ await waitForUserLogin(page, null, "Anthropic");
+
+ console.log(" 📍 Navigating to API keys...");
+ await page.goto("https://console.anthropic.com/settings/keys", { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ console.log("");
+ console.log(" 💡 In the browser window:");
+ console.log(" 1. Click 'Create Key'");
+ console.log(" 2. Copy the key that appears");
+ console.log(" 3. Paste it below");
+ console.log("");
+
+ const key = await askUser(" Paste your ANTHROPIC_API_KEY (or Enter to skip): ");
+ if (key) {
+ results.ANTHROPIC_API_KEY = key;
+ console.log(" ✅ ANTHROPIC_API_KEY saved");
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/grabbers/browser.js b/env-agent-finder/src/grabbers/browser.js
new file mode 100644
index 0000000..ae16fe6
--- /dev/null
+++ b/env-agent-finder/src/grabbers/browser.js
@@ -0,0 +1,105 @@
+const puppeteer = require("puppeteer");
+const readline = require("readline");
+
+let browser = null;
+
+async function launchBrowser() {
+ if (browser) return browser;
+
+ browser = await puppeteer.launch({
+ headless: false,
+ defaultViewport: { width: 1280, height: 800 },
+ args: [
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--window-size=1280,800",
+ ],
+ });
+
+ browser.on("disconnected", () => { browser = null; });
+ return browser;
+}
+
+async function closeBrowser() {
+ if (browser) {
+ await browser.close();
+ browser = null;
+ }
+}
+
+async function openPage(url) {
+ const b = await launchBrowser();
+ const page = await b.newPage();
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
+ return page;
+}
+
+async function waitForUserLogin(page, checkSelector, serviceName) {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
+
+ console.log("");
+ console.log(` 🔐 ${serviceName}: Please log in using the browser window.`);
+ console.log(` Once you're logged in, press ENTER here to continue...`);
+ console.log("");
+
+ await new Promise((resolve) => {
+ rl.question(" Press ENTER when logged in > ", () => {
+ rl.close();
+ resolve();
+ });
+ });
+
+ if (checkSelector) {
+ try {
+ await page.waitForSelector(checkSelector, { timeout: 5000 });
+ } catch {
+ // User said they're logged in, trust them
+ }
+ }
+}
+
+async function extractText(page, selector, timeout = 5000) {
+ try {
+ await page.waitForSelector(selector, { timeout });
+ return await page.$eval(selector, (el) => el.textContent.trim());
+ } catch {
+ return null;
+ }
+}
+
+async function extractInputValue(page, selector, timeout = 5000) {
+ try {
+ await page.waitForSelector(selector, { timeout });
+ return await page.$eval(selector, (el) => el.value || el.textContent?.trim());
+ } catch {
+ return null;
+ }
+}
+
+async function clickAndWait(page, selector, waitMs = 2000) {
+ try {
+ await page.click(selector);
+ await page.waitForTimeout(waitMs);
+ } catch {}
+}
+
+async function askUser(question) {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
+ return new Promise((resolve) => {
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer.trim());
+ });
+ });
+}
+
+module.exports = {
+ launchBrowser,
+ closeBrowser,
+ openPage,
+ waitForUserLogin,
+ extractText,
+ extractInputValue,
+ clickAndWait,
+ askUser,
+};
diff --git a/env-agent-finder/src/grabbers/clerk.js b/env-agent-finder/src/grabbers/clerk.js
new file mode 100644
index 0000000..0382700
--- /dev/null
+++ b/env-agent-finder/src/grabbers/clerk.js
@@ -0,0 +1,64 @@
+const { openPage, waitForUserLogin, askUser } = require("./browser");
+
+module.exports = {
+ name: "Clerk",
+ vars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening Clerk dashboard...");
+ const page = await openPage("https://dashboard.clerk.com/sign-in");
+
+ await waitForUserLogin(page, null, "Clerk");
+
+ console.log(" 📍 Looking for API keys...");
+ await page.waitForTimeout(2000);
+
+ // Try to navigate to API keys
+ try {
+ const extracted = await page.evaluate(() => {
+ const text = document.body.innerText;
+ const result = {};
+
+ const pkMatch = text.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/);
+ if (pkMatch) result.publishable = pkMatch[1];
+
+ const skMatch = text.match(/(sk_(?:test|live)_[A-Za-z0-9]+)/);
+ if (skMatch) result.secret = skMatch[1];
+
+ return result;
+ });
+
+ if (extracted.publishable) {
+ results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = extracted.publishable;
+ console.log(` ✅ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = ${extracted.publishable.substring(0, 20)}...`);
+ }
+
+ if (extracted.secret) {
+ results.CLERK_SECRET_KEY = extracted.secret;
+ console.log(` ✅ CLERK_SECRET_KEY = ${extracted.secret.substring(0, 15)}...`);
+ }
+ } catch {}
+
+ console.log("");
+ console.log(" 💡 In the Clerk dashboard:");
+ console.log(" 1. Select your application");
+ console.log(" 2. Go to 'API Keys' in the sidebar");
+ console.log(" 3. Copy the keys shown");
+ console.log("");
+
+ if (!results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
+ const pk = await askUser(" Paste NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (or Enter to skip): ");
+ if (pk) results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = pk;
+ }
+
+ if (!results.CLERK_SECRET_KEY) {
+ const sk = await askUser(" Paste CLERK_SECRET_KEY (or Enter to skip): ");
+ if (sk) results.CLERK_SECRET_KEY = sk;
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/grabbers/index.js b/env-agent-finder/src/grabbers/index.js
new file mode 100644
index 0000000..18a4fe3
--- /dev/null
+++ b/env-agent-finder/src/grabbers/index.js
@@ -0,0 +1,32 @@
+const stripe = require("./stripe");
+const openai = require("./openai");
+const supabase = require("./supabase");
+const vercel = require("./vercel");
+const clerk = require("./clerk");
+const anthropic = require("./anthropic");
+
+const ALL_GRABBERS = [stripe, openai, anthropic, supabase, vercel, clerk];
+
+function findGrabbersForServices(services, missingVars) {
+ const missingSet = new Set(missingVars.map((v) => v.name));
+ const needed = [];
+
+ for (const grabber of ALL_GRABBERS) {
+ const hasOverlap = grabber.vars.some((v) => missingSet.has(v));
+ const serviceDetected = services.some(
+ (s) => s.name.toLowerCase().includes(grabber.name.toLowerCase()) ||
+ grabber.name.toLowerCase().includes(s.name.toLowerCase())
+ );
+
+ if (hasOverlap || serviceDetected) {
+ const relevantVars = grabber.vars.filter((v) => missingSet.has(v));
+ if (relevantVars.length > 0) {
+ needed.push({ grabber, relevantVars });
+ }
+ }
+ }
+
+ return needed;
+}
+
+module.exports = { ALL_GRABBERS, findGrabbersForServices };
diff --git a/env-agent-finder/src/grabbers/openai.js b/env-agent-finder/src/grabbers/openai.js
new file mode 100644
index 0000000..bb0a646
--- /dev/null
+++ b/env-agent-finder/src/grabbers/openai.js
@@ -0,0 +1,44 @@
+const { openPage, waitForUserLogin, askUser } = require("./browser");
+
+module.exports = {
+ name: "OpenAI",
+ vars: ["OPENAI_API_KEY"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening OpenAI dashboard...");
+ const page = await openPage("https://platform.openai.com/login");
+
+ await waitForUserLogin(page, null, "OpenAI");
+
+ console.log(" 📍 Navigating to API keys...");
+ await page.goto("https://platform.openai.com/api-keys", { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ console.log(" 📍 Looking for 'Create new secret key' button...");
+
+ try {
+ const createBtn = await page.$('button:has-text("Create new secret key"), [data-testid="create-api-key-button"]');
+ if (createBtn) {
+ console.log("");
+ console.log(" 💡 To grab a key automatically:");
+ console.log(" 1. Click 'Create new secret key' in the browser");
+ console.log(" 2. Give it a name (e.g. 'env-agent-finder')");
+ console.log(" 3. Copy the key that appears");
+ console.log(" 4. Paste it below");
+ console.log("");
+ }
+ } catch {}
+
+ // OpenAI never shows existing keys in full, so we always need the user to create/paste
+ const key = await askUser(" Paste your OPENAI_API_KEY (or Enter to skip): ");
+ if (key) {
+ results.OPENAI_API_KEY = key;
+ console.log(" ✅ OPENAI_API_KEY saved");
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/grabbers/stripe.js b/env-agent-finder/src/grabbers/stripe.js
new file mode 100644
index 0000000..c8b77be
--- /dev/null
+++ b/env-agent-finder/src/grabbers/stripe.js
@@ -0,0 +1,68 @@
+const { openPage, waitForUserLogin, extractText, clickAndWait, askUser } = require("./browser");
+
+module.exports = {
+ name: "Stripe",
+ vars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening Stripe dashboard...");
+ const page = await openPage("https://dashboard.stripe.com/login");
+
+ await waitForUserLogin(page, '[data-testid="developers-nav-item"]', "Stripe");
+
+ console.log(" 📍 Navigating to API keys...");
+ await page.goto("https://dashboard.stripe.com/test/apikeys", { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ // Try to extract publishable key
+ try {
+ const pkKey = await page.evaluate(() => {
+ const rows = document.querySelectorAll('[class*="KeyRow"], tr, [data-testid]');
+ for (const row of rows) {
+ const text = row.textContent || "";
+ if (text.includes("pk_test_") || text.includes("pk_live_")) {
+ const match = text.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/);
+ if (match) return match[1];
+ }
+ }
+ const all = document.body.innerText;
+ const m = all.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/);
+ return m ? m[1] : null;
+ });
+
+ if (pkKey) {
+ results.STRIPE_PUBLISHABLE_KEY = pkKey;
+ console.log(` ✅ STRIPE_PUBLISHABLE_KEY = ${pkKey.substring(0, 20)}...`);
+ }
+ } catch {}
+
+ // Secret key needs to be revealed
+ try {
+ const skKey = await page.evaluate(() => {
+ const all = document.body.innerText;
+ const m = all.match(/(sk_(?:test|live)_[A-Za-z0-9]+)/);
+ return m ? m[1] : null;
+ });
+
+ if (skKey) {
+ results.STRIPE_SECRET_KEY = skKey;
+ console.log(` ✅ STRIPE_SECRET_KEY = ${skKey.substring(0, 15)}...`);
+ } else {
+ console.log(" ⚠️ Secret key is hidden. Click 'Reveal test key' in the browser, then:");
+ const manual = await askUser(" Paste your STRIPE_SECRET_KEY (or Enter to skip): ");
+ if (manual) results.STRIPE_SECRET_KEY = manual;
+ }
+ } catch {}
+
+ // Publishable key fallback
+ if (!results.STRIPE_PUBLISHABLE_KEY) {
+ const manual = await askUser(" Paste your STRIPE_PUBLISHABLE_KEY (or Enter to skip): ");
+ if (manual) results.STRIPE_PUBLISHABLE_KEY = manual;
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/grabbers/supabase.js b/env-agent-finder/src/grabbers/supabase.js
new file mode 100644
index 0000000..227ffc0
--- /dev/null
+++ b/env-agent-finder/src/grabbers/supabase.js
@@ -0,0 +1,89 @@
+const { openPage, waitForUserLogin, askUser } = require("./browser");
+
+module.exports = {
+ name: "Supabase",
+ vars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening Supabase dashboard...");
+ const page = await openPage("https://supabase.com/dashboard/sign-in");
+
+ await waitForUserLogin(page, null, "Supabase");
+
+ console.log(" 📍 Navigating to projects...");
+ await page.goto("https://supabase.com/dashboard/projects", { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ // Ask which project to use
+ console.log("");
+ console.log(" 💡 Select your project in the browser window, then:");
+ const projectRef = await askUser(" Paste your Supabase project URL (e.g. https://supabase.com/dashboard/project/abc123) or project ref: ");
+
+ let ref = projectRef;
+ if (projectRef.includes("/project/")) {
+ ref = projectRef.split("/project/")[1].split(/[/?#]/)[0];
+ }
+
+ if (ref) {
+ console.log(` 📍 Opening project settings for: ${ref}`);
+ await page.goto(`https://supabase.com/dashboard/project/${ref}/settings/api`, { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ // Try to extract values from the API settings page
+ try {
+ const extracted = await page.evaluate(() => {
+ const text = document.body.innerText;
+ const result = {};
+
+ // Project URL
+ const urlMatch = text.match(/(https:\/\/[a-z0-9]+\.supabase\.co)/);
+ if (urlMatch) result.url = urlMatch[1];
+
+ // Anon key (starts with eyJ)
+ const anonMatch = text.match(/(eyJ[A-Za-z0-9_-]{100,})/);
+ if (anonMatch) result.anonKey = anonMatch[1];
+
+ return result;
+ });
+
+ if (extracted.url) {
+ results.SUPABASE_URL = extracted.url;
+ results.NEXT_PUBLIC_SUPABASE_URL = extracted.url;
+ console.log(` ✅ SUPABASE_URL = ${extracted.url}`);
+ }
+
+ if (extracted.anonKey) {
+ results.SUPABASE_ANON_KEY = extracted.anonKey;
+ results.NEXT_PUBLIC_SUPABASE_ANON_KEY = extracted.anonKey;
+ console.log(` ✅ SUPABASE_ANON_KEY = ${extracted.anonKey.substring(0, 30)}...`);
+ }
+ } catch {}
+
+ // Service role key is usually hidden — ask user to reveal and paste
+ if (!results.SUPABASE_SERVICE_ROLE_KEY) {
+ console.log("");
+ console.log(" 💡 For the service_role key, click 'Reveal' next to it in the browser, then:");
+ const srKey = await askUser(" Paste your SUPABASE_SERVICE_ROLE_KEY (or Enter to skip): ");
+ if (srKey) {
+ results.SUPABASE_SERVICE_ROLE_KEY = srKey;
+ console.log(" ✅ SUPABASE_SERVICE_ROLE_KEY saved");
+ }
+ }
+ }
+
+ // Fallbacks
+ if (!results.SUPABASE_URL) {
+ const url = await askUser(" Paste your SUPABASE_URL (or Enter to skip): ");
+ if (url) { results.SUPABASE_URL = url; results.NEXT_PUBLIC_SUPABASE_URL = url; }
+ }
+ if (!results.SUPABASE_ANON_KEY) {
+ const key = await askUser(" Paste your SUPABASE_ANON_KEY (or Enter to skip): ");
+ if (key) { results.SUPABASE_ANON_KEY = key; results.NEXT_PUBLIC_SUPABASE_ANON_KEY = key; }
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/grabbers/vercel.js b/env-agent-finder/src/grabbers/vercel.js
new file mode 100644
index 0000000..15770f6
--- /dev/null
+++ b/env-agent-finder/src/grabbers/vercel.js
@@ -0,0 +1,35 @@
+const { openPage, waitForUserLogin, askUser } = require("./browser");
+
+module.exports = {
+ name: "Vercel",
+ vars: ["BLOB_READ_WRITE_TOKEN"],
+
+ async grab() {
+ const results = {};
+
+ console.log(" 📍 Opening Vercel dashboard...");
+ const page = await openPage("https://vercel.com/login");
+
+ await waitForUserLogin(page, null, "Vercel");
+
+ console.log(" 📍 Navigating to Blob stores...");
+ await page.goto("https://vercel.com/dashboard/stores", { waitUntil: "networkidle2", timeout: 30000 });
+ await page.waitForTimeout(3000);
+
+ console.log("");
+ console.log(" 💡 In the browser window:");
+ console.log(" 1. Click on your Blob store (or create one)");
+ console.log(" 2. Go to the store settings");
+ console.log(" 3. Find and copy the BLOB_READ_WRITE_TOKEN");
+ console.log("");
+
+ const token = await askUser(" Paste your BLOB_READ_WRITE_TOKEN (or Enter to skip): ");
+ if (token) {
+ results.BLOB_READ_WRITE_TOKEN = token;
+ console.log(" ✅ BLOB_READ_WRITE_TOKEN saved");
+ }
+
+ await page.close();
+ return results;
+ },
+};
diff --git a/env-agent-finder/src/index.js b/env-agent-finder/src/index.js
new file mode 100644
index 0000000..6f9cab5
--- /dev/null
+++ b/env-agent-finder/src/index.js
@@ -0,0 +1,22 @@
+const { scanEnvVars } = require("./scanners/env-scanner");
+const { scanApiRoutes } = require("./scanners/api-scanner");
+const { scanServices } = require("./scanners/service-scanner");
+const { scanProjectMeta } = require("./scanners/meta-scanner");
+
+async function scanProject(targetDir) {
+ const meta = await scanProjectMeta(targetDir);
+ const envVars = await scanEnvVars(targetDir);
+ const apiRoutes = await scanApiRoutes(targetDir);
+ const services = await scanServices(targetDir, envVars);
+
+ return {
+ targetDir,
+ scannedAt: new Date().toISOString(),
+ meta,
+ envVars,
+ apiRoutes,
+ services,
+ };
+}
+
+module.exports = { scanProject };
diff --git a/env-agent-finder/src/license.js b/env-agent-finder/src/license.js
new file mode 100644
index 0000000..808a2e4
--- /dev/null
+++ b/env-agent-finder/src/license.js
@@ -0,0 +1,75 @@
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+const os = require("os");
+
+const LICENSE_FILE = path.join(os.homedir(), ".env-agent-finder-license");
+const PUBLIC_PREFIX = "EAF";
+
+function generateLicenseKey() {
+ const seg = () => crypto.randomBytes(4).toString("hex").toUpperCase();
+ return `${PUBLIC_PREFIX}-${seg()}-${seg()}-${seg()}-${seg()}`;
+}
+
+function validateKeyFormat(key) {
+ return /^EAF-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}$/.test(key);
+}
+
+function hashKey(key) {
+ return crypto.createHash("sha256").update(key + "env-agent-finder-salt-2026").digest("hex");
+}
+
+function saveLicense(key) {
+ fs.writeFileSync(LICENSE_FILE, JSON.stringify({ key, hash: hashKey(key), activated: new Date().toISOString() }), "utf-8");
+}
+
+function loadLicense() {
+ try {
+ if (fs.existsSync(LICENSE_FILE)) {
+ const data = JSON.parse(fs.readFileSync(LICENSE_FILE, "utf-8"));
+ if (data.key && validateKeyFormat(data.key) && data.hash === hashKey(data.key)) {
+ return data;
+ }
+ }
+ } catch {}
+ return null;
+}
+
+function isLicensed() {
+ return !!loadLicense();
+}
+
+function activateLicense(key) {
+ if (!validateKeyFormat(key)) {
+ return { success: false, error: "Invalid license key format. Expected: EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX" };
+ }
+ saveLicense(key);
+ return { success: true };
+}
+
+function deactivateLicense() {
+ try {
+ if (fs.existsSync(LICENSE_FILE)) fs.unlinkSync(LICENSE_FILE);
+ return { success: true };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+}
+
+function getLicenseInfo() {
+ const lic = loadLicense();
+ if (!lic) return null;
+ return {
+ key: lic.key.substring(0, 12) + "..." + lic.key.substring(lic.key.length - 8),
+ activated: lic.activated,
+ };
+}
+
+module.exports = {
+ generateLicenseKey,
+ validateKeyFormat,
+ isLicensed,
+ activateLicense,
+ deactivateLicense,
+ getLicenseInfo,
+};
diff --git a/env-agent-finder/src/provisioner.js b/env-agent-finder/src/provisioner.js
new file mode 100644
index 0000000..99e34a6
--- /dev/null
+++ b/env-agent-finder/src/provisioner.js
@@ -0,0 +1,499 @@
+const fs = require("fs");
+const path = require("path");
+const crypto = require("crypto");
+const readline = require("readline");
+
+function generateSecret(length = 64) {
+ return crypto.randomBytes(length).toString("base64url").substring(0, length);
+}
+
+const AUTO_GENERATORS = {
+ NEXTAUTH_SECRET: () => generateSecret(48),
+ AUTH_SECRET: () => generateSecret(48),
+ JWT_SECRET: () => generateSecret(48),
+ NEXTAUTH_URL: () => "http://localhost:3000", // pragma: allowlist secret
+ NEXT_PUBLIC_APP_URL: () => "http://localhost:3000", // pragma: allowlist secret
+ APP_URL: () => "http://localhost:3000", // pragma: allowlist secret
+ APP_SECRET: () => generateSecret(48),
+ ENCRYPTION_KEY: () => generateSecret(32),
+ SESSION_SECRET: () => generateSecret(48),
+ COOKIE_SECRET: () => generateSecret(48),
+ SECRET_KEY: () => generateSecret(48),
+ API_SECRET: () => generateSecret(48),
+};
+
+const LOCAL_DB_GENERATORS = {
+ DATABASE_URL: (ctx) => {
+ if (ctx.services.has("PostgreSQL")) return "postgresql://postgres:postgres@localhost:5432/app_dev";
+ if (ctx.services.has("MySQL")) return "mysql://root:root@localhost:3306/app_dev";
+ if (ctx.services.has("MongoDB")) return "mongodb://localhost:27017/app_dev";
+ return "postgresql://postgres:postgres@localhost:5432/app_dev";
+ },
+ POSTGRES_URL: () => "postgresql://postgres:postgres@localhost:5432/app_dev",
+ PG_CONNECTION_STRING: () => "postgresql://postgres:postgres@localhost:5432/app_dev",
+ MYSQL_URL: () => "mysql://root:root@localhost:3306/app_dev",
+ MYSQL_HOST: () => "localhost",
+ MYSQL_DATABASE: () => "app_dev",
+ MONGODB_URI: () => "mongodb://localhost:27017/app_dev",
+ MONGO_URL: () => "mongodb://localhost:27017/app_dev",
+ REDIS_URL: () => "redis://localhost:6379",
+};
+
+const SERVICE_SIGNUP_INFO = {
+ "Stripe": {
+ vars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"],
+ signupUrl: "https://dashboard.stripe.com/register",
+ keysUrl: "https://dashboard.stripe.com/apikeys",
+ steps: [
+ "1. Go to https://dashboard.stripe.com/register and create an account",
+ "2. Go to https://dashboard.stripe.com/apikeys",
+ "3. Copy your Publishable key → STRIPE_PUBLISHABLE_KEY",
+ "4. Copy your Secret key → STRIPE_SECRET_KEY",
+ "5. For webhooks: go to Developers > Webhooks > Add endpoint",
+ "6. Copy the Signing secret → STRIPE_WEBHOOK_SECRET",
+ ],
+ },
+ "OpenAI": {
+ vars: ["OPENAI_API_KEY"],
+ signupUrl: "https://platform.openai.com/signup",
+ keysUrl: "https://platform.openai.com/api-keys",
+ steps: [
+ "1. Go to https://platform.openai.com/signup and create an account",
+ "2. Go to https://platform.openai.com/api-keys",
+ "3. Click 'Create new secret key'",
+ "4. Copy the key → OPENAI_API_KEY",
+ ],
+ },
+ "Anthropic": {
+ vars: ["ANTHROPIC_API_KEY"],
+ signupUrl: "https://console.anthropic.com/",
+ keysUrl: "https://console.anthropic.com/settings/keys",
+ steps: [
+ "1. Go to https://console.anthropic.com/ and create an account",
+ "2. Go to https://console.anthropic.com/settings/keys",
+ "3. Click 'Create Key'",
+ "4. Copy the key → ANTHROPIC_API_KEY",
+ ],
+ },
+ "Clerk": {
+ vars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"],
+ signupUrl: "https://dashboard.clerk.com/sign-up",
+ keysUrl: "https://dashboard.clerk.com/ → your app → API Keys",
+ steps: [
+ "1. Go to https://dashboard.clerk.com/sign-up and create an account",
+ "2. Create an application",
+ "3. Go to API Keys in the sidebar",
+ "4. Copy Publishable key → NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
+ "5. Copy Secret key → CLERK_SECRET_KEY",
+ ],
+ },
+ "Stack Auth": {
+ vars: ["STACK_PROJECT_ID", "STACK_PUBLISHED_CLIENT_KEY", "STACK_SECRET_SERVER_KEY"],
+ signupUrl: "https://app.stack-auth.com/",
+ keysUrl: "https://app.stack-auth.com/ → your project → Settings",
+ steps: [
+ "1. Go to https://app.stack-auth.com/ and create an account",
+ "2. Create a project",
+ "3. Go to project Settings → API Keys",
+ "4. Copy Project ID → STACK_PROJECT_ID",
+ "5. Copy Publishable Client Key → STACK_PUBLISHED_CLIENT_KEY",
+ "6. Copy Secret Server Key → STACK_SECRET_SERVER_KEY",
+ ],
+ },
+ "Supabase": {
+ vars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY"],
+ signupUrl: "https://supabase.com/dashboard",
+ keysUrl: "https://supabase.com/dashboard → your project → Settings → API",
+ steps: [
+ "1. Go to https://supabase.com/dashboard and create an account",
+ "2. Create a new project",
+ "3. Go to Settings → API",
+ "4. Copy Project URL → SUPABASE_URL / NEXT_PUBLIC_SUPABASE_URL",
+ "5. Copy anon/public key → SUPABASE_ANON_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY",
+ "6. Copy service_role key → SUPABASE_SERVICE_ROLE_KEY",
+ ],
+ },
+ "Firebase": {
+ vars: ["FIREBASE_API_KEY", "FIREBASE_PROJECT_ID", "FIREBASE_AUTH_DOMAIN", "FIREBASE_STORAGE_BUCKET"],
+ signupUrl: "https://console.firebase.google.com/",
+ keysUrl: "https://console.firebase.google.com/ → Project settings",
+ steps: [
+ "1. Go to https://console.firebase.google.com/ and create a project",
+ "2. Go to Project settings (gear icon)",
+ "3. Scroll to 'Your apps' → Add a web app",
+ "4. Copy the config values into your env vars",
+ ],
+ },
+ "Vercel Blob": {
+ vars: ["BLOB_READ_WRITE_TOKEN"],
+ signupUrl: "https://vercel.com/signup",
+ keysUrl: "https://vercel.com/dashboard/stores",
+ steps: [
+ "1. Go to https://vercel.com/signup and create an account",
+ "2. Go to Storage → Create a Blob Store",
+ "3. Copy the BLOB_READ_WRITE_TOKEN from the store settings",
+ ],
+ },
+ "AWS S3": {
+ vars: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "S3_BUCKET"],
+ signupUrl: "https://aws.amazon.com/",
+ keysUrl: "https://console.aws.amazon.com/iam/home#/security_credentials",
+ steps: [
+ "1. Go to https://aws.amazon.com/ and create an account",
+ "2. Go to IAM → Security credentials → Create access key",
+ "3. Copy Access key ID → AWS_ACCESS_KEY_ID",
+ "4. Copy Secret access key → AWS_SECRET_ACCESS_KEY",
+ "5. Set AWS_REGION (e.g. us-east-1)",
+ "6. Create an S3 bucket and set S3_BUCKET to its name",
+ ],
+ },
+ "SendGrid": {
+ vars: ["SENDGRID_API_KEY"],
+ signupUrl: "https://signup.sendgrid.com/",
+ keysUrl: "https://app.sendgrid.com/settings/api_keys",
+ steps: [
+ "1. Go to https://signup.sendgrid.com/ and create an account",
+ "2. Go to Settings → API Keys → Create API Key",
+ "3. Copy the key → SENDGRID_API_KEY",
+ ],
+ },
+ "Resend": {
+ vars: ["RESEND_API_KEY"],
+ signupUrl: "https://resend.com/signup",
+ keysUrl: "https://resend.com/api-keys",
+ steps: [
+ "1. Go to https://resend.com/signup and create an account",
+ "2. Go to API Keys → Create API Key",
+ "3. Copy the key → RESEND_API_KEY",
+ ],
+ },
+ "Sentry": {
+ vars: ["SENTRY_DSN", "SENTRY_AUTH_TOKEN"],
+ signupUrl: "https://sentry.io/signup/",
+ keysUrl: "https://sentry.io → your project → Settings → Client Keys (DSN)",
+ steps: [
+ "1. Go to https://sentry.io/signup/ and create an account",
+ "2. Create a project for your platform",
+ "3. Copy the DSN → SENTRY_DSN",
+ ],
+ },
+ "Google OAuth": {
+ vars: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
+ signupUrl: "https://console.cloud.google.com/",
+ keysUrl: "https://console.cloud.google.com/apis/credentials",
+ steps: [
+ "1. Go to https://console.cloud.google.com/ and create a project",
+ "2. Go to APIs & Services → Credentials → Create OAuth 2.0 Client",
+ "3. Set authorized redirect URIs",
+ "4. Copy Client ID → GOOGLE_CLIENT_ID",
+ "5. Copy Client Secret → GOOGLE_CLIENT_SECRET",
+ ],
+ },
+ "GitHub OAuth": {
+ vars: ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"],
+ signupUrl: "https://github.com/settings/developers",
+ keysUrl: "https://github.com/settings/developers",
+ steps: [
+ "1. Go to https://github.com/settings/developers",
+ "2. Click 'New OAuth App'",
+ "3. Set the callback URL (e.g. http://localhost:PORT/api/auth/callback/github)", // pragma: allowlist secret
+ "4. Copy Client ID → GITHUB_CLIENT_ID",
+ "5. Copy Client Secret → GITHUB_CLIENT_SECRET",
+ ],
+ },
+ "Twilio": {
+ vars: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"],
+ signupUrl: "https://www.twilio.com/try-twilio",
+ keysUrl: "https://console.twilio.com/",
+ steps: [
+ "1. Go to https://www.twilio.com/try-twilio and create an account",
+ "2. Your Account SID and Auth Token are on the Console dashboard",
+ "3. Copy Account SID → TWILIO_ACCOUNT_SID",
+ "4. Copy Auth Token → TWILIO_AUTH_TOKEN",
+ ],
+ },
+ "Cloudinary": {
+ vars: ["CLOUDINARY_URL", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET", "CLOUDINARY_CLOUD_NAME"],
+ signupUrl: "https://cloudinary.com/users/register_free",
+ keysUrl: "https://console.cloudinary.com/settings/api-keys",
+ steps: [
+ "1. Go to https://cloudinary.com/users/register_free and create an account",
+ "2. Go to Settings → API Keys",
+ "3. Copy your credentials into the env vars",
+ ],
+ },
+ "Upstash Redis": {
+ vars: ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
+ signupUrl: "https://console.upstash.com/",
+ keysUrl: "https://console.upstash.com/ → your database → REST API",
+ steps: [
+ "1. Go to https://console.upstash.com/ and create an account",
+ "2. Create a Redis database",
+ "3. Go to the REST API tab",
+ "4. Copy URL → UPSTASH_REDIS_REST_URL",
+ "5. Copy Token → UPSTASH_REDIS_REST_TOKEN",
+ ],
+ },
+};
+
+function createReadlineInterface() {
+ return readline.createInterface({
+ input: process.stdin,
+ output: process.stderr,
+ });
+}
+
+function ask(rl, question) {
+ return new Promise((resolve) => {
+ rl.question(question, (answer) => resolve(answer.trim()));
+ });
+}
+
+async function provisionEnv(targetDir, scanReport, options = {}) {
+ const { interactive = true, autoOnly = false } = options;
+ const { envVars, services } = scanReport;
+
+ const serviceNames = new Set(services.map((s) => s.name));
+ const ctx = { services: serviceNames };
+
+ const result = {
+ generated: [],
+ prompted: [],
+ skipped: [],
+ written: false,
+ envFilePath: null,
+ };
+
+ const finalEnv = {};
+ const existingValues = {};
+
+ for (const v of envVars) {
+ if (v.hasValue && v.value) {
+ existingValues[v.name] = v.value;
+ }
+ }
+
+ const allNeededVars = new Set();
+
+ for (const v of envVars) {
+ allNeededVars.add(v.name);
+ }
+ for (const svc of services) {
+ for (const envVar of svc.requiredEnvVars || []) {
+ allNeededVars.add(envVar);
+ }
+ }
+
+ const NODE_INTERNALS = new Set([
+ "NODE_ENV", "PORT", "HOST", "HOSTNAME", "HOME", "PATH", "PWD",
+ "LANG", "TERM", "SHELL", "USER", "TZ", "CI", "DEBUG",
+ "VERCEL", "VERCEL_ENV", "VERCEL_URL",
+ ]);
+
+ const varsToProvision = Array.from(allNeededVars).filter(
+ (name) => !NODE_INTERNALS.has(name)
+ );
+
+ console.log("");
+ console.log("═".repeat(60));
+ console.log(" ENV AGENT FINDER — Setup Mode");
+ console.log("═".repeat(60));
+ console.log("");
+ console.log(` Found ${varsToProvision.length} environment variable(s) needed.`);
+ console.log(` Detected ${services.length} service(s): ${services.map((s) => s.name).join(", ") || "none"}`);
+ console.log("");
+
+ // Phase 1: Keep existing values
+ for (const name of varsToProvision) {
+ if (existingValues[name]) {
+ finalEnv[name] = existingValues[name];
+ result.generated.push({ name, value: existingValues[name], source: "existing" });
+ }
+ }
+
+ // Phase 2: Auto-generate secrets and local DB URLs
+ console.log("⚡ AUTO-GENERATING values...");
+ console.log("─".repeat(40));
+
+ for (const name of varsToProvision) {
+ if (finalEnv[name]) continue;
+
+ if (AUTO_GENERATORS[name]) {
+ const value = AUTO_GENERATORS[name]();
+ finalEnv[name] = value;
+ result.generated.push({ name, value, source: "auto-generated" });
+ console.log(` ✅ ${name} = ${value.substring(0, 30)}${value.length > 30 ? "..." : ""}`);
+ } else if (LOCAL_DB_GENERATORS[name]) {
+ const value = LOCAL_DB_GENERATORS[name](ctx);
+ finalEnv[name] = value;
+ result.generated.push({ name, value, source: "local-dev-default" });
+ console.log(` ✅ ${name} = ${value}`);
+ }
+ }
+ console.log("");
+
+ // Phase 3: Interactive prompts for external services
+ const remainingVars = varsToProvision.filter((name) => !finalEnv[name]);
+
+ if (remainingVars.length > 0 && interactive && !autoOnly) {
+ const serviceVarMap = new Map();
+
+ for (const name of remainingVars) {
+ let matched = false;
+ for (const [serviceName, info] of Object.entries(SERVICE_SIGNUP_INFO)) {
+ if (info.vars.includes(name)) {
+ if (!serviceVarMap.has(serviceName)) {
+ serviceVarMap.set(serviceName, { info, vars: [] });
+ }
+ serviceVarMap.get(serviceName).vars.push(name);
+ matched = true;
+ break;
+ }
+ }
+ if (!matched) {
+ if (!serviceVarMap.has("__unknown__")) {
+ serviceVarMap.set("__unknown__", { info: null, vars: [] });
+ }
+ serviceVarMap.get("__unknown__").vars.push(name);
+ }
+ }
+
+ const rl = createReadlineInterface();
+
+ for (const [serviceName, { info, vars }] of serviceVarMap) {
+ if (serviceName === "__unknown__") continue;
+
+ console.log(`🔑 ${serviceName.toUpperCase()}`);
+ console.log("─".repeat(40));
+ if (info) {
+ console.log(" How to get your keys:");
+ for (const step of info.steps) {
+ console.log(` ${step}`);
+ }
+ console.log("");
+ }
+
+ for (const name of vars) {
+ const answer = await ask(rl, ` Enter ${name} (or press Enter to skip): `);
+ if (answer) {
+ finalEnv[name] = answer;
+ result.prompted.push({ name, source: serviceName });
+ console.log(` ✅ ${name} saved`);
+ } else {
+ result.skipped.push({ name, service: serviceName, signupUrl: info?.signupUrl });
+ console.log(` ⏭️ ${name} skipped`);
+ }
+ }
+ console.log("");
+ }
+
+ const unknownEntry = serviceVarMap.get("__unknown__");
+ if (unknownEntry && unknownEntry.vars.length > 0) {
+ console.log("❓ OTHER VARIABLES");
+ console.log("─".repeat(40));
+
+ for (const name of unknownEntry.vars) {
+ const answer = await ask(rl, ` Enter ${name} (or press Enter to skip): `);
+ if (answer) {
+ finalEnv[name] = answer;
+ result.prompted.push({ name, source: "manual" });
+ console.log(` ✅ ${name} saved`);
+ } else {
+ result.skipped.push({ name, service: null, signupUrl: null });
+ console.log(` ⏭️ ${name} skipped`);
+ }
+ }
+ console.log("");
+ }
+
+ rl.close();
+ } else if (remainingVars.length > 0) {
+ for (const name of remainingVars) {
+ let serviceName = null;
+ let signupUrl = null;
+ for (const [svcName, info] of Object.entries(SERVICE_SIGNUP_INFO)) {
+ if (info.vars.includes(name)) {
+ serviceName = svcName;
+ signupUrl = info.signupUrl;
+ break;
+ }
+ }
+ result.skipped.push({ name, service: serviceName, signupUrl });
+ }
+ }
+
+ // Phase 4: Write .env.local
+ const envFilePath = path.join(targetDir, ".env.local");
+ const envLines = [];
+
+ envLines.push("# Generated by env-agent-finder --setup");
+ envLines.push(`# ${new Date().toISOString()}`);
+ envLines.push("");
+
+ const groupedByService = new Map();
+ const varServiceMap = {};
+
+ for (const svc of services) {
+ for (const envVar of svc.requiredEnvVars || []) {
+ varServiceMap[envVar] = svc.name;
+ }
+ }
+ for (const [serviceName, info] of Object.entries(SERVICE_SIGNUP_INFO)) {
+ for (const v of info.vars) {
+ if (!varServiceMap[v]) varServiceMap[v] = serviceName;
+ }
+ }
+
+ for (const name of varsToProvision) {
+ const svc = varServiceMap[name] || "Other";
+ if (!groupedByService.has(svc)) groupedByService.set(svc, []);
+ groupedByService.get(svc).push(name);
+ }
+
+ for (const [service, vars] of groupedByService) {
+ envLines.push(`# ${service}`);
+ for (const name of vars) {
+ const value = finalEnv[name] || "";
+ envLines.push(`${name}=${value}`);
+ }
+ envLines.push("");
+ }
+
+ fs.writeFileSync(envFilePath, envLines.join("\n"), "utf-8");
+ result.written = true;
+ result.envFilePath = envFilePath;
+
+ // Summary
+ console.log("═".repeat(60));
+ console.log(" SETUP COMPLETE");
+ console.log("═".repeat(60));
+ console.log("");
+ console.log(` 📄 Written to: ${envFilePath}`);
+ console.log("");
+
+ const existingCount = result.generated.filter((g) => g.source === "existing").length;
+ const autoCount = result.generated.filter((g) => g.source !== "existing").length;
+
+ if (existingCount > 0) console.log(` ♻️ ${existingCount} value(s) kept from existing .env`);
+ if (autoCount > 0) console.log(` ⚡ ${autoCount} value(s) auto-generated (secrets, DB URLs)`);
+ if (result.prompted.length > 0) console.log(` 🔑 ${result.prompted.length} value(s) provided by you`);
+
+ if (result.skipped.length > 0) {
+ console.log(` ⏭️ ${result.skipped.length} value(s) still need to be filled in:`);
+ console.log("");
+ for (const skip of result.skipped) {
+ const where = skip.signupUrl ? ` → ${skip.signupUrl}` : "";
+ console.log(` ❌ ${skip.name}${skip.service ? ` (${skip.service})` : ""}${where}`);
+ }
+ } else {
+ console.log("");
+ console.log(" 🎉 All environment variables are configured!");
+ }
+
+ console.log("");
+ console.log("═".repeat(60));
+
+ return result;
+}
+
+module.exports = { provisionEnv };
diff --git a/env-agent-finder/src/reporter.js b/env-agent-finder/src/reporter.js
new file mode 100644
index 0000000..660ce9b
--- /dev/null
+++ b/env-agent-finder/src/reporter.js
@@ -0,0 +1,340 @@
+function maskValue(val) {
+ if (!val || val.length === 0) return "(empty)";
+ if (val.length <= 8) return val.substring(0, 2) + "•".repeat(val.length - 2);
+ return val.substring(0, 4) + "•".repeat(val.length - 8) + val.substring(val.length - 4);
+}
+
+function displayValue(val, source, opts) {
+ if (!val) return null;
+ if (opts.unmask) return val;
+ return maskValue(val);
+}
+
+function renderTerminal(report, opts) {
+ const lines = [];
+ const { meta, envVars, apiRoutes, services } = report;
+
+ lines.push("═".repeat(70));
+ lines.push(" ENV AGENT FINDER — Scan Report");
+ lines.push("═".repeat(70));
+ lines.push("");
+
+ // Project meta
+ lines.push("📦 PROJECT INFO");
+ lines.push("─".repeat(50));
+ if (meta.name) lines.push(` Name: ${meta.name}`);
+ if (meta.framework) lines.push(` Framework: ${meta.framework}`);
+ if (meta.language) lines.push(` Language: ${meta.language}`);
+ if (meta.packageManager) lines.push(` Package Manager: ${meta.packageManager}`);
+ lines.push(` Docker: ${meta.hasDocker ? "Yes" : "No"}`);
+ lines.push(` CI/CD: ${meta.hasCI ? "Yes" : "No"}`);
+ lines.push("");
+
+ // Env vars
+ lines.push(`🔑 ENVIRONMENT VARIABLES (${envVars.length} found)`);
+ lines.push("─".repeat(50));
+
+ if (envVars.length === 0) {
+ lines.push(" No environment variables detected.");
+ } else if (opts.showValues) {
+ for (const v of envVars) {
+ const status = v.hasValue ? "✅" : v.required === false ? "⚪" : "❌";
+ lines.push("");
+ lines.push(` ${status} ${v.name}`);
+ if (v.service) lines.push(` Service: ${v.service}`);
+ lines.push(` Refs: ${v.references.length} reference(s) in code`);
+
+ if (v.values && v.values.length > 0) {
+ for (const entry of v.values) {
+ const shown = displayValue(entry.value, entry.source, opts);
+ lines.push(` Value: ${shown}`);
+ lines.push(` Source: ${entry.source}`);
+ }
+ } else if (v.hasValue && v.value) {
+ const shown = displayValue(v.value, v.valueSource, opts);
+ lines.push(` Value: ${shown}`);
+ if (v.valueSource) lines.push(` Source: ${v.valueSource}`);
+ } else {
+ lines.push(` Value: (not set)`);
+ }
+ }
+ } else {
+ const maxName = Math.max(...envVars.map((v) => v.name.length), 10);
+ lines.push(
+ ` ${"Variable".padEnd(maxName)} ${"Service".padEnd(25)} ${"Refs".padEnd(4)} Status`
+ );
+ lines.push(` ${"─".repeat(maxName)} ${"─".repeat(25)} ${"─".repeat(4)} ──────`);
+ for (const v of envVars) {
+ const status = v.hasValue ? "✅ Set" : v.required === false ? "⚪ Optional" : "❌ Missing";
+ const service = (v.service || "—").substring(0, 25);
+ lines.push(
+ ` ${v.name.padEnd(maxName)} ${service.padEnd(25)} ${String(v.references.length).padEnd(4)} ${status}`
+ );
+ }
+ lines.push("");
+ lines.push(" 💡 Use --show-values to see values, or --unmask for full values.");
+ }
+ lines.push("");
+
+ // Services
+ lines.push(`🔌 DETECTED SERVICES (${services.length} found)`);
+ lines.push("─".repeat(50));
+ if (services.length === 0) {
+ lines.push(" No external services detected.");
+ } else {
+ for (const svc of services) {
+ const bar = "█".repeat(Math.round(svc.confidence / 10)) +
+ "░".repeat(10 - Math.round(svc.confidence / 10));
+ lines.push(` ${svc.name}`);
+ lines.push(` Category: ${svc.category}`);
+ lines.push(` Confidence: ${bar} ${svc.confidence}%`);
+ if (svc.matchedPackages.length > 0) {
+ lines.push(` Packages: ${svc.matchedPackages.join(", ")}`);
+ }
+ if (svc.matchedEnvVars.length > 0) {
+ lines.push(` Env Vars: ${svc.matchedEnvVars.join(", ")}`);
+ }
+ if (svc.matchedFiles.length > 0) {
+ lines.push(` Files: ${svc.matchedFiles.join(", ")}`);
+ }
+ lines.push(` Docs: ${svc.docs}`);
+ lines.push("");
+ }
+ }
+
+ // API routes
+ lines.push(`🌐 API ROUTES (${apiRoutes.length} found)`);
+ lines.push("─".repeat(50));
+ if (apiRoutes.length === 0) {
+ lines.push(" No API routes detected.");
+ } else {
+ const maxPath = Math.max(...apiRoutes.map((r) => r.path.length), 10);
+ for (const route of apiRoutes) {
+ const methods = route.methods.join(", ");
+ lines.push(` ${route.path.padEnd(maxPath)} [${methods}]`);
+ }
+ }
+ lines.push("");
+
+ // .env template for missing vars
+ const missingVars = envVars.filter((v) => !v.hasValue);
+ if (missingVars.length > 0) {
+ lines.push("📋 SUGGESTED .env.local TEMPLATE");
+ lines.push("─".repeat(50));
+ lines.push("");
+ let currentService = null;
+ for (const v of missingVars) {
+ const svc = v.service || "Other";
+ if (svc !== currentService) {
+ if (currentService !== null) lines.push("");
+ lines.push(` # ${svc}`);
+ currentService = svc;
+ }
+ lines.push(` ${v.name}=`);
+ }
+ lines.push("");
+ }
+
+ // Current .env snapshot (when --show-values is used)
+ const setVars = envVars.filter((v) => v.hasValue && v.values && v.values.length > 0);
+ if (opts.showValues && setVars.length > 0) {
+ lines.push("📄 CURRENT .env VALUES");
+ lines.push("─".repeat(50));
+ lines.push("");
+ for (const v of setVars) {
+ const val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value);
+ lines.push(` ${v.name}=${val}`);
+ }
+ lines.push("");
+ if (!opts.unmask) {
+ lines.push(" 🔒 Values are masked. Use --unmask to reveal full values.");
+ lines.push("");
+ }
+ }
+
+ lines.push("═".repeat(70));
+ lines.push(` Scanned: ${report.targetDir}`);
+ lines.push(` Time: ${report.scannedAt}`);
+ lines.push("═".repeat(70));
+
+ return lines.join("\n");
+}
+
+function renderJson(report, opts) {
+ const output = { ...report };
+
+ if (!opts.showValues) {
+ output.envVars = output.envVars.map((v) => {
+ const { value, values, ...rest } = v;
+ return rest;
+ });
+ } else if (!opts.unmask) {
+ output.envVars = output.envVars.map((v) => ({
+ ...v,
+ value: v.value ? maskValue(v.value) : null,
+ values: (v.values || []).map((entry) => ({
+ ...entry,
+ value: maskValue(entry.value),
+ })),
+ }));
+ }
+
+ return JSON.stringify(output, null, 2);
+}
+
+function renderMarkdown(report, opts) {
+ const lines = [];
+ const { meta, envVars, apiRoutes, services } = report;
+
+ lines.push("# Environment & API Scan Report");
+ lines.push("");
+ lines.push(`> Scanned: \`${report.targetDir}\` `);
+ lines.push(`> Time: ${report.scannedAt}`);
+ lines.push("");
+
+ // Meta
+ lines.push("## Project Info");
+ lines.push("");
+ lines.push("| Property | Value |");
+ lines.push("|----------|-------|");
+ if (meta.name) lines.push(`| Name | ${meta.name} |`);
+ if (meta.framework) lines.push(`| Framework | ${meta.framework} |`);
+ if (meta.language) lines.push(`| Language | ${meta.language} |`);
+ if (meta.packageManager) lines.push(`| Package Manager | ${meta.packageManager} |`);
+ lines.push(`| Docker | ${meta.hasDocker ? "Yes" : "No"} |`);
+ lines.push(`| CI/CD | ${meta.hasCI ? "Yes" : "No"} |`);
+ lines.push("");
+
+ // Env vars
+ lines.push(`## Environment Variables (${envVars.length})`);
+ lines.push("");
+
+ if (envVars.length > 0) {
+ if (opts.showValues) {
+ lines.push("| Variable | Service | Value | Source | Status |");
+ lines.push("|----------|---------|-------|--------|--------|");
+ for (const v of envVars) {
+ const status = v.hasValue ? "Set" : v.required === false ? "Optional" : "**Missing**";
+ let val = "(not set)";
+ let source = "—";
+ if (v.values && v.values.length > 0) {
+ val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value);
+ source = v.values[0].source;
+ } else if (v.hasValue && v.value) {
+ val = opts.unmask ? v.value : maskValue(v.value);
+ source = v.valueSource || "—";
+ }
+ lines.push(
+ `| \`${v.name}\` | ${v.service || "—"} | \`${val}\` | ${source} | ${status} |`
+ );
+ }
+ } else {
+ lines.push("| Variable | Service | References | Status |");
+ lines.push("|----------|---------|------------|--------|");
+ for (const v of envVars) {
+ const status = v.hasValue ? "Set" : v.required === false ? "Optional" : "**Missing**";
+ lines.push(
+ `| \`${v.name}\` | ${v.service || "—"} | ${v.references.length} | ${status} |`
+ );
+ }
+ }
+ } else {
+ lines.push("No environment variables detected.");
+ }
+ lines.push("");
+
+ // Services
+ lines.push(`## Detected Services (${services.length})`);
+ lines.push("");
+ if (services.length > 0) {
+ for (const svc of services) {
+ lines.push(`### ${svc.name} (${svc.category})`);
+ lines.push(`- **Confidence:** ${svc.confidence}%`);
+ if (svc.matchedPackages.length > 0) {
+ lines.push(`- **Packages:** ${svc.matchedPackages.map((p) => "`" + p + "`").join(", ")}`);
+ }
+ if (svc.matchedEnvVars.length > 0) {
+ lines.push(`- **Env Vars:** ${svc.matchedEnvVars.map((v) => "`" + v + "`").join(", ")}`);
+ }
+ if (svc.requiredEnvVars.length > 0) {
+ lines.push(`- **Required Env Vars:** ${svc.requiredEnvVars.map((v) => "`" + v + "`").join(", ")}`);
+ }
+ lines.push(`- **Docs:** ${svc.docs}`);
+ lines.push("");
+ }
+ } else {
+ lines.push("No external services detected.");
+ lines.push("");
+ }
+
+ // API routes
+ lines.push(`## API Routes (${apiRoutes.length})`);
+ lines.push("");
+ if (apiRoutes.length > 0) {
+ lines.push("| Path | Methods | Framework | File |");
+ lines.push("|------|---------|-----------|------|");
+ for (const route of apiRoutes) {
+ lines.push(
+ `| \`${route.path}\` | ${route.methods.join(", ")} | ${route.framework} | \`${route.file}\` |`
+ );
+ }
+ } else {
+ lines.push("No API routes detected.");
+ }
+ lines.push("");
+
+ // .env template
+ const missingVars = envVars.filter((v) => !v.hasValue);
+ if (missingVars.length > 0) {
+ lines.push("## Suggested `.env.local` Template");
+ lines.push("");
+ lines.push("```bash");
+ let currentService = null;
+ for (const v of missingVars) {
+ const svc = v.service || "Other";
+ if (svc !== currentService) {
+ if (currentService !== null) lines.push("");
+ lines.push(`# ${svc}`);
+ currentService = svc;
+ }
+ lines.push(`${v.name}=`);
+ }
+ lines.push("```");
+ }
+
+ // Current .env snapshot
+ const setVars = envVars.filter((v) => v.hasValue && v.values && v.values.length > 0);
+ if (opts.showValues && setVars.length > 0) {
+ lines.push("");
+ lines.push("## Current `.env` Values");
+ lines.push("");
+ lines.push("```bash");
+ for (const v of setVars) {
+ const val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value);
+ lines.push(`${v.name}=${val}`);
+ }
+ lines.push("```");
+ if (!opts.unmask) {
+ lines.push("");
+ lines.push("> Values are masked. Use `--unmask` to reveal full values.");
+ }
+ }
+
+ return lines.join("\n");
+}
+
+function renderReport(report, format, opts = {}) {
+ const options = { showValues: false, unmask: false, ...opts };
+ switch (format) {
+ case "json":
+ return renderJson(report, options);
+ case "markdown":
+ case "md":
+ return renderMarkdown(report, options);
+ case "terminal":
+ default:
+ return renderTerminal(report, options);
+ }
+}
+
+module.exports = { renderReport };
diff --git a/env-agent-finder/src/scanners/api-scanner.js b/env-agent-finder/src/scanners/api-scanner.js
new file mode 100644
index 0000000..ddac1e1
--- /dev/null
+++ b/env-agent-finder/src/scanners/api-scanner.js
@@ -0,0 +1,233 @@
+const fs = require("fs");
+const path = require("path");
+
+const SKIP_DIRS = new Set([
+ "node_modules", ".next", ".nuxt", "dist", "build", ".git",
+ "coverage", "__pycache__", ".venv", "venv", "vendor", "target",
+ ".turbo", ".cache", ".output", "stubs",
+]);
+
+function walkDirs(dir, results = []) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return results;
+ }
+ for (const entry of entries) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(fullPath);
+ walkDirs(fullPath, results);
+ }
+ }
+ return results;
+}
+
+function scanNextJsRoutes(targetDir) {
+ const routes = [];
+ const appDir = path.join(targetDir, "app");
+ const apiDir = path.join(appDir, "api");
+
+ if (!fs.existsSync(apiDir)) return routes;
+
+ const dirs = walkDirs(apiDir);
+ dirs.unshift(apiDir);
+
+ for (const dir of dirs) {
+ const routeFile = ["route.ts", "route.js"].find((f) =>
+ fs.existsSync(path.join(dir, f))
+ );
+ if (!routeFile) continue;
+
+ const filePath = path.join(dir, routeFile);
+ const relativePath = path.relative(appDir, dir);
+ const routePath = "/" + relativePath.replace(/\\/g, "/");
+
+ let content;
+ try {
+ content = fs.readFileSync(filePath, "utf-8");
+ } catch {
+ continue;
+ }
+
+ const methods = [];
+ const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
+ for (const method of httpMethods) {
+ const pattern = new RegExp(
+ `export\\s+(?:async\\s+)?function\\s+${method}\\b`,
+ );
+ if (pattern.test(content)) {
+ methods.push(method);
+ }
+ }
+
+ if (methods.length > 0) {
+ routes.push({
+ path: routePath,
+ methods,
+ file: path.relative(targetDir, filePath),
+ framework: "Next.js App Router",
+ });
+ }
+ }
+
+ return routes;
+}
+
+function scanExpressRoutes(targetDir) {
+ const routes = [];
+ const srcDirs = [
+ targetDir,
+ path.join(targetDir, "src"),
+ path.join(targetDir, "routes"),
+ path.join(targetDir, "src", "routes"),
+ path.join(targetDir, "api"),
+ path.join(targetDir, "src", "api"),
+ ];
+
+ for (const dir of srcDirs) {
+ if (!fs.existsSync(dir)) continue;
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ const ext = path.extname(entry.name);
+ if (![".js", ".ts", ".mjs"].includes(ext)) continue;
+
+ const filePath = path.join(dir, entry.name);
+ let content;
+ try {
+ content = fs.readFileSync(filePath, "utf-8");
+ } catch {
+ continue;
+ }
+
+ const routePattern =
+ /(?:app|router|server)\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
+ let match;
+ while ((match = routePattern.exec(content)) !== null) {
+ const method = match[1].toUpperCase();
+ const routePath = match[2];
+ const existing = routes.find((r) => r.path === routePath);
+ if (existing) {
+ if (!existing.methods.includes(method)) existing.methods.push(method);
+ } else {
+ routes.push({
+ path: routePath,
+ methods: [method],
+ file: path.relative(targetDir, filePath),
+ framework: "Express/Fastify",
+ });
+ }
+ }
+ }
+ }
+
+ return routes;
+}
+
+function scanPagesApiRoutes(targetDir) {
+ const routes = [];
+ const pagesApiDir = path.join(targetDir, "pages", "api");
+ if (!fs.existsSync(pagesApiDir)) return routes;
+
+ const dirs = walkDirs(pagesApiDir);
+ dirs.unshift(pagesApiDir);
+
+ for (const dir of dirs) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ const ext = path.extname(entry.name);
+ if (![".ts", ".js", ".tsx", ".jsx"].includes(ext)) continue;
+
+ const filePath = path.join(dir, entry.name);
+ const relativePath = path.relative(pagesApiDir, filePath);
+ const routeName = relativePath
+ .replace(/\\/g, "/")
+ .replace(/\.(ts|js|tsx|jsx)$/, "")
+ .replace(/\/index$/, "");
+ const routePath = "/api/" + routeName;
+
+ routes.push({
+ path: routePath,
+ methods: ["handler"],
+ file: path.relative(targetDir, filePath),
+ framework: "Next.js Pages Router",
+ });
+ }
+ }
+
+ return routes;
+}
+
+function scanFastApiRoutes(targetDir) {
+ const routes = [];
+ const pyFiles = [];
+
+ function findPyFiles(dir) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) findPyFiles(fullPath);
+ else if (entry.isFile() && entry.name.endsWith(".py")) pyFiles.push(fullPath);
+ }
+ }
+
+ findPyFiles(targetDir);
+
+ for (const filePath of pyFiles) {
+ let content;
+ try {
+ content = fs.readFileSync(filePath, "utf-8");
+ } catch {
+ continue;
+ }
+
+ const pattern =
+ /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/gi;
+ let match;
+ while ((match = pattern.exec(content)) !== null) {
+ routes.push({
+ path: match[2],
+ methods: [match[1].toUpperCase()],
+ file: path.relative(targetDir, filePath),
+ framework: "FastAPI/Flask",
+ });
+ }
+ }
+
+ return routes;
+}
+
+async function scanApiRoutes(targetDir) {
+ const allRoutes = [
+ ...scanNextJsRoutes(targetDir),
+ ...scanPagesApiRoutes(targetDir),
+ ...scanExpressRoutes(targetDir),
+ ...scanFastApiRoutes(targetDir),
+ ];
+
+ return allRoutes.sort((a, b) => a.path.localeCompare(b.path));
+}
+
+module.exports = { scanApiRoutes };
diff --git a/env-agent-finder/src/scanners/env-scanner.js b/env-agent-finder/src/scanners/env-scanner.js
new file mode 100644
index 0000000..997b080
--- /dev/null
+++ b/env-agent-finder/src/scanners/env-scanner.js
@@ -0,0 +1,251 @@
+const fs = require("fs");
+const path = require("path");
+
+const SKIP_DIRS = new Set([
+ "node_modules", ".next", ".nuxt", "dist", "build", ".git",
+ "coverage", "__pycache__", ".venv", "venv", "vendor", "target",
+ ".turbo", ".cache", ".output", "stubs",
+]);
+
+const CODE_EXTENSIONS = new Set([
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
+ ".py", ".go", ".rs", ".rb", ".java", ".kt",
+ ".env", ".env.local", ".env.example", ".env.development",
+ ".env.production", ".env.test",
+ ".yml", ".yaml", ".toml", ".json", ".tf",
+]);
+
+const WELL_KNOWN_SERVICES = {
+ DATABASE_URL: { service: "Database (PostgreSQL/MySQL)", required: true },
+ POSTGRES_URL: { service: "PostgreSQL", required: true },
+ MYSQL_URL: { service: "MySQL", required: true },
+ MONGODB_URI: { service: "MongoDB", required: true },
+ REDIS_URL: { service: "Redis", required: false },
+ BLOB_READ_WRITE_TOKEN: { service: "Vercel Blob Storage", required: false },
+ NEXT_PUBLIC_SUPABASE_URL: { service: "Supabase", required: true },
+ SUPABASE_URL: { service: "Supabase", required: true },
+ SUPABASE_SERVICE_ROLE_KEY: { service: "Supabase", required: true },
+ STRIPE_SECRET_KEY: { service: "Stripe", required: true },
+ STRIPE_PUBLISHABLE_KEY: { service: "Stripe", required: true },
+ STRIPE_WEBHOOK_SECRET: { service: "Stripe Webhooks", required: false },
+ OPENAI_API_KEY: { service: "OpenAI", required: true },
+ ANTHROPIC_API_KEY: { service: "Anthropic", required: true },
+ AWS_ACCESS_KEY_ID: { service: "AWS", required: true },
+ AWS_SECRET_ACCESS_KEY: { service: "AWS", required: true },
+ AWS_REGION: { service: "AWS", required: false },
+ S3_BUCKET: { service: "AWS S3", required: false },
+ GOOGLE_CLIENT_ID: { service: "Google OAuth", required: false },
+ GOOGLE_CLIENT_SECRET: { service: "Google OAuth", required: false },
+ GITHUB_CLIENT_ID: { service: "GitHub OAuth", required: false },
+ GITHUB_CLIENT_SECRET: { service: "GitHub OAuth", required: false },
+ NEXTAUTH_SECRET: { service: "NextAuth.js", required: true },
+ NEXTAUTH_URL: { service: "NextAuth.js", required: false },
+ AUTH_SECRET: { service: "Auth.js", required: true },
+ JWT_SECRET: { service: "JWT Auth", required: true },
+ SENDGRID_API_KEY: { service: "SendGrid Email", required: false },
+ RESEND_API_KEY: { service: "Resend Email", required: false },
+ SMTP_HOST: { service: "SMTP Email", required: false },
+ SENTRY_DSN: { service: "Sentry Error Tracking", required: false },
+ VERCEL_URL: { service: "Vercel Platform", required: false },
+ CLERK_SECRET_KEY: { service: "Clerk Auth", required: true },
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: { service: "Clerk Auth", required: true },
+ STACK_PROJECT_ID: { service: "Stack Auth", required: true },
+ STACK_PUBLISHED_CLIENT_KEY: { service: "Stack Auth", required: true },
+ STACK_SECRET_SERVER_KEY: { service: "Stack Auth", required: true },
+ TWILIO_ACCOUNT_SID: { service: "Twilio", required: false },
+ TWILIO_AUTH_TOKEN: { service: "Twilio", required: false },
+ CLOUDINARY_URL: { service: "Cloudinary", required: false },
+ UPSTASH_REDIS_REST_URL: { service: "Upstash Redis", required: false },
+ UPSTASH_REDIS_REST_TOKEN: { service: "Upstash Redis", required: false },
+ FIREBASE_API_KEY: { service: "Firebase", required: true },
+ FIREBASE_PROJECT_ID: { service: "Firebase", required: true },
+};
+
+function walkFiles(dir, files = []) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return files;
+ }
+
+ for (const entry of entries) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkFiles(fullPath, files);
+ } else if (entry.isFile()) {
+ const ext = path.extname(entry.name).toLowerCase();
+ const basename = entry.name.toLowerCase();
+ if (CODE_EXTENSIONS.has(ext) || basename.startsWith(".env")) {
+ files.push(fullPath);
+ }
+ }
+ }
+ return files;
+}
+
+function extractEnvReferences(content, filePath) {
+ const refs = [];
+
+ const patterns = [
+ /process\.env\.([A-Z_][A-Z0-9_]*)/g,
+ /process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g,
+ /os\.environ(?:\.get)?\(?['"]([A-Z_][A-Z0-9_]*)['"]/g,
+ /os\.getenv\(['"]([A-Z_][A-Z0-9_]*)['"]\)/g,
+ /env(?:ironment)?\.([A-Z_][A-Z0-9_]*)/g,
+ /\$\{([A-Z_][A-Z0-9_]*)\}/g,
+ /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
+ ];
+
+ for (const pattern of patterns) {
+ let match;
+ while ((match = pattern.exec(content)) !== null) {
+ refs.push({
+ name: match[1],
+ file: filePath,
+ line: content.substring(0, match.index).split("\n").length,
+ });
+ }
+ }
+
+ return refs;
+}
+
+function stripQuotes(val) {
+ if ((val.startsWith('"') && val.endsWith('"')) ||
+ (val.startsWith("'") && val.endsWith("'"))) {
+ return val.slice(1, -1);
+ }
+ return val;
+}
+
+function parseEnvFile(filePath) {
+ const vars = [];
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const lines = content.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line || line.startsWith("#")) continue;
+ const eqIdx = line.indexOf("=");
+ if (eqIdx > 0) {
+ const name = line.substring(0, eqIdx).trim();
+ const rawValue = line.substring(eqIdx + 1).trim();
+ const value = stripQuotes(rawValue);
+ if (/^[A-Z_][A-Z0-9_]*$/.test(name)) {
+ vars.push({
+ name,
+ value: value || null,
+ hasValue: value.length > 0,
+ file: filePath,
+ line: i + 1,
+ });
+ }
+ }
+ }
+ } catch {}
+ return vars;
+}
+
+async function scanEnvVars(targetDir) {
+ const files = walkFiles(targetDir);
+ const allRefs = [];
+ const envFileVars = [];
+
+ for (const filePath of files) {
+ const basename = path.basename(filePath).toLowerCase();
+
+ if (basename.startsWith(".env")) {
+ envFileVars.push(...parseEnvFile(filePath));
+ continue;
+ }
+
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const relativePath = path.relative(targetDir, filePath);
+ const refs = extractEnvReferences(content, relativePath);
+ allRefs.push(...refs);
+ } catch {}
+ }
+
+ const varMap = new Map();
+
+ for (const ref of allRefs) {
+ if (!varMap.has(ref.name)) {
+ const known = WELL_KNOWN_SERVICES[ref.name];
+ varMap.set(ref.name, {
+ name: ref.name,
+ references: [],
+ service: known?.service || null,
+ required: known?.required ?? null,
+ hasValue: false,
+ value: null,
+ valueSource: null,
+ values: [],
+ });
+ }
+ varMap.get(ref.name).references.push({ file: ref.file, line: ref.line });
+ }
+
+ for (const envVar of envFileVars) {
+ const relFile = path.relative(targetDir, envVar.file);
+ if (!varMap.has(envVar.name)) {
+ const known = WELL_KNOWN_SERVICES[envVar.name];
+ varMap.set(envVar.name, {
+ name: envVar.name,
+ references: [{ file: relFile, line: envVar.line }],
+ service: known?.service || null,
+ required: known?.required ?? null,
+ hasValue: envVar.hasValue,
+ value: envVar.value,
+ valueSource: envVar.hasValue ? relFile : null,
+ values: envVar.hasValue
+ ? [{ value: envVar.value, source: relFile }]
+ : [],
+ });
+ } else {
+ const existing = varMap.get(envVar.name);
+ existing.references.push({ file: relFile, line: envVar.line });
+ if (envVar.hasValue) {
+ existing.values.push({ value: envVar.value, source: relFile });
+ if (!existing.hasValue) {
+ existing.hasValue = true;
+ existing.value = envVar.value;
+ existing.valueSource = relFile;
+ }
+ }
+ }
+ }
+
+ // Also check live process.env for any vars we found in code
+ for (const [name, entry] of varMap) {
+ const liveValue = process.env[name];
+ if (liveValue !== undefined && liveValue.length > 0) {
+ entry.values.push({ value: liveValue, source: "process.env (runtime)" });
+ if (!entry.hasValue) {
+ entry.hasValue = true;
+ entry.value = liveValue;
+ entry.valueSource = "process.env (runtime)";
+ }
+ }
+ }
+
+ const NODE_INTERNALS = new Set([
+ "NODE_ENV", "PORT", "HOST", "HOSTNAME", "HOME", "PATH", "PWD",
+ "LANG", "TERM", "SHELL", "USER", "TZ", "CI", "DEBUG",
+ "VERCEL", "VERCEL_ENV", "VERCEL_URL",
+ ]);
+
+ const results = Array.from(varMap.values())
+ .filter((v) => !NODE_INTERNALS.has(v.name))
+ .sort((a, b) => {
+ if (a.required && !b.required) return -1;
+ if (!a.required && b.required) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ return results;
+}
+
+module.exports = { scanEnvVars };
diff --git a/env-agent-finder/src/scanners/meta-scanner.js b/env-agent-finder/src/scanners/meta-scanner.js
new file mode 100644
index 0000000..e6e7c7e
--- /dev/null
+++ b/env-agent-finder/src/scanners/meta-scanner.js
@@ -0,0 +1,75 @@
+const fs = require("fs");
+const path = require("path");
+
+async function scanProjectMeta(targetDir) {
+ const meta = {
+ name: null,
+ framework: null,
+ language: null,
+ packageManager: null,
+ hasDocker: false,
+ hasCI: false,
+ };
+
+ const pkgPath = path.join(targetDir, "package.json");
+ if (fs.existsSync(pkgPath)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+ meta.name = pkg.name || null;
+ meta.language = "JavaScript/TypeScript";
+
+ if (pkg.dependencies?.next || pkg.devDependencies?.next) {
+ meta.framework = `Next.js ${pkg.dependencies?.next || pkg.devDependencies?.next}`;
+ } else if (pkg.dependencies?.nuxt || pkg.devDependencies?.nuxt) {
+ meta.framework = `Nuxt ${pkg.dependencies?.nuxt || pkg.devDependencies?.nuxt}`;
+ } else if (pkg.dependencies?.react || pkg.devDependencies?.react) {
+ meta.framework = `React ${pkg.dependencies?.react || pkg.devDependencies?.react}`;
+ } else if (pkg.dependencies?.vue || pkg.devDependencies?.vue) {
+ meta.framework = `Vue ${pkg.dependencies?.vue || pkg.devDependencies?.vue}`;
+ } else if (pkg.dependencies?.express || pkg.devDependencies?.express) {
+ meta.framework = `Express ${pkg.dependencies?.express || pkg.devDependencies?.express}`;
+ } else if (pkg.dependencies?.fastify || pkg.devDependencies?.fastify) {
+ meta.framework = `Fastify ${pkg.dependencies?.fastify || pkg.devDependencies?.fastify}`;
+ }
+ } catch {}
+ }
+
+ if (fs.existsSync(path.join(targetDir, "requirements.txt")) ||
+ fs.existsSync(path.join(targetDir, "pyproject.toml")) ||
+ fs.existsSync(path.join(targetDir, "setup.py"))) {
+ meta.language = "Python";
+ if (fs.existsSync(path.join(targetDir, "pyproject.toml"))) {
+ try {
+ const content = fs.readFileSync(path.join(targetDir, "pyproject.toml"), "utf-8");
+ if (content.includes("django")) meta.framework = "Django";
+ else if (content.includes("fastapi")) meta.framework = "FastAPI";
+ else if (content.includes("flask")) meta.framework = "Flask";
+ } catch {}
+ }
+ }
+
+ if (fs.existsSync(path.join(targetDir, "go.mod"))) {
+ meta.language = "Go";
+ }
+
+ if (fs.existsSync(path.join(targetDir, "Cargo.toml"))) {
+ meta.language = "Rust";
+ }
+
+ if (fs.existsSync(path.join(targetDir, "pnpm-lock.yaml"))) meta.packageManager = "pnpm";
+ else if (fs.existsSync(path.join(targetDir, "yarn.lock"))) meta.packageManager = "yarn";
+ else if (fs.existsSync(path.join(targetDir, "bun.lockb"))) meta.packageManager = "bun";
+ else if (fs.existsSync(path.join(targetDir, "package-lock.json"))) meta.packageManager = "npm";
+
+ meta.hasDocker = fs.existsSync(path.join(targetDir, "Dockerfile")) ||
+ fs.existsSync(path.join(targetDir, "docker-compose.yml")) ||
+ fs.existsSync(path.join(targetDir, "docker-compose.yaml"));
+
+ meta.hasCI = fs.existsSync(path.join(targetDir, ".github", "workflows")) ||
+ fs.existsSync(path.join(targetDir, ".gitlab-ci.yml")) ||
+ fs.existsSync(path.join(targetDir, ".circleci"));
+
+ return meta;
+}
+
+module.exports = { scanProjectMeta };
diff --git a/env-agent-finder/src/scanners/service-scanner.js b/env-agent-finder/src/scanners/service-scanner.js
new file mode 100644
index 0000000..3ce1d51
--- /dev/null
+++ b/env-agent-finder/src/scanners/service-scanner.js
@@ -0,0 +1,262 @@
+const fs = require("fs");
+const path = require("path");
+
+const SERVICE_SIGNATURES = [
+ {
+ name: "PostgreSQL",
+ category: "database",
+ indicators: {
+ packages: ["pg", "@neondatabase/serverless", "postgres", "knex", "prisma", "drizzle-orm", "typeorm", "sequelize"],
+ envVars: ["DATABASE_URL", "POSTGRES_URL", "PG_CONNECTION_STRING", "PGHOST"],
+ files: ["prisma/schema.prisma", "drizzle.config.ts", "drizzle.config.js"],
+ },
+ docs: "https://www.postgresql.org/docs/",
+ },
+ {
+ name: "MySQL",
+ category: "database",
+ indicators: {
+ packages: ["mysql2", "mysql"],
+ envVars: ["MYSQL_URL", "MYSQL_HOST", "MYSQL_DATABASE"],
+ },
+ docs: "https://dev.mysql.com/doc/",
+ },
+ {
+ name: "MongoDB",
+ category: "database",
+ indicators: {
+ packages: ["mongodb", "mongoose"],
+ envVars: ["MONGODB_URI", "MONGO_URL"],
+ },
+ docs: "https://www.mongodb.com/docs/",
+ },
+ {
+ name: "Redis",
+ category: "cache",
+ indicators: {
+ packages: ["redis", "ioredis", "@upstash/redis"],
+ envVars: ["REDIS_URL", "UPSTASH_REDIS_REST_URL"],
+ },
+ docs: "https://redis.io/docs/",
+ },
+ {
+ name: "Supabase",
+ category: "baas",
+ indicators: {
+ packages: ["@supabase/supabase-js", "@supabase/ssr"],
+ envVars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"],
+ },
+ docs: "https://supabase.com/docs",
+ },
+ {
+ name: "Firebase",
+ category: "baas",
+ indicators: {
+ packages: ["firebase", "firebase-admin"],
+ envVars: ["FIREBASE_API_KEY", "FIREBASE_PROJECT_ID"],
+ files: ["firebase.json", ".firebaserc"],
+ },
+ docs: "https://firebase.google.com/docs",
+ },
+ {
+ name: "Stripe",
+ category: "payments",
+ indicators: {
+ packages: ["stripe", "@stripe/stripe-js"],
+ envVars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"],
+ },
+ docs: "https://stripe.com/docs",
+ },
+ {
+ name: "Auth.js / NextAuth",
+ category: "auth",
+ indicators: {
+ packages: ["next-auth", "@auth/core"],
+ envVars: ["NEXTAUTH_SECRET", "AUTH_SECRET", "NEXTAUTH_URL"],
+ },
+ docs: "https://authjs.dev/",
+ },
+ {
+ name: "Clerk",
+ category: "auth",
+ indicators: {
+ packages: ["@clerk/nextjs", "@clerk/clerk-sdk-node"],
+ envVars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"],
+ },
+ docs: "https://clerk.com/docs",
+ },
+ {
+ name: "Stack Auth",
+ category: "auth",
+ indicators: {
+ packages: ["@stackframe/stack", "@stack-auth/nextjs"],
+ envVars: ["STACK_PROJECT_ID", "STACK_SECRET_SERVER_KEY"],
+ },
+ docs: "https://docs.stack-auth.com/",
+ },
+ {
+ name: "Vercel Blob",
+ category: "storage",
+ indicators: {
+ packages: ["@vercel/blob"],
+ envVars: ["BLOB_READ_WRITE_TOKEN"],
+ },
+ docs: "https://vercel.com/docs/storage/vercel-blob",
+ },
+ {
+ name: "AWS S3",
+ category: "storage",
+ indicators: {
+ packages: ["@aws-sdk/client-s3", "aws-sdk"],
+ envVars: ["AWS_ACCESS_KEY_ID", "S3_BUCKET"],
+ },
+ docs: "https://docs.aws.amazon.com/s3/",
+ },
+ {
+ name: "Cloudinary",
+ category: "storage",
+ indicators: {
+ packages: ["cloudinary"],
+ envVars: ["CLOUDINARY_URL", "CLOUDINARY_API_KEY"],
+ },
+ docs: "https://cloudinary.com/documentation",
+ },
+ {
+ name: "OpenAI",
+ category: "ai",
+ indicators: {
+ packages: ["openai", "@langchain/openai"],
+ envVars: ["OPENAI_API_KEY"],
+ },
+ docs: "https://platform.openai.com/docs",
+ },
+ {
+ name: "Anthropic",
+ category: "ai",
+ indicators: {
+ packages: ["@anthropic-ai/sdk"],
+ envVars: ["ANTHROPIC_API_KEY"],
+ },
+ docs: "https://docs.anthropic.com/",
+ },
+ {
+ name: "SendGrid",
+ category: "email",
+ indicators: {
+ packages: ["@sendgrid/mail"],
+ envVars: ["SENDGRID_API_KEY"],
+ },
+ docs: "https://docs.sendgrid.com/",
+ },
+ {
+ name: "Resend",
+ category: "email",
+ indicators: {
+ packages: ["resend"],
+ envVars: ["RESEND_API_KEY"],
+ },
+ docs: "https://resend.com/docs",
+ },
+ {
+ name: "Sentry",
+ category: "monitoring",
+ indicators: {
+ packages: ["@sentry/nextjs", "@sentry/node", "@sentry/browser"],
+ envVars: ["SENTRY_DSN"],
+ },
+ docs: "https://docs.sentry.io/",
+ },
+ {
+ name: "Twilio",
+ category: "messaging",
+ indicators: {
+ packages: ["twilio"],
+ envVars: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"],
+ },
+ docs: "https://www.twilio.com/docs",
+ },
+ {
+ name: "Vercel Analytics",
+ category: "analytics",
+ indicators: {
+ packages: ["@vercel/analytics"],
+ envVars: [],
+ },
+ docs: "https://vercel.com/docs/analytics",
+ },
+ {
+ name: "Docker",
+ category: "infrastructure",
+ indicators: {
+ packages: [],
+ envVars: [],
+ files: ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"],
+ },
+ docs: "https://docs.docker.com/",
+ },
+];
+
+async function scanServices(targetDir, envVars) {
+ const detectedServices = [];
+
+ let packageDeps = new Set();
+ const pkgPath = path.join(targetDir, "package.json");
+ if (fs.existsSync(pkgPath)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+ const allDeps = {
+ ...pkg.dependencies,
+ ...pkg.devDependencies,
+ ...pkg.peerDependencies,
+ };
+ packageDeps = new Set(Object.keys(allDeps));
+ } catch {}
+ }
+
+ const reqPath = path.join(targetDir, "requirements.txt");
+ if (fs.existsSync(reqPath)) {
+ try {
+ const content = fs.readFileSync(reqPath, "utf-8");
+ content.split("\n").forEach((line) => {
+ const pkg = line.trim().split(/[>= v.name));
+
+ for (const sig of SERVICE_SIGNATURES) {
+ const matchedPackages = sig.indicators.packages?.filter((p) =>
+ packageDeps.has(p)
+ ) || [];
+ const matchedEnvVars = sig.indicators.envVars?.filter((v) =>
+ envVarNames.has(v)
+ ) || [];
+ const matchedFiles = sig.indicators.files?.filter((f) =>
+ fs.existsSync(path.join(targetDir, f))
+ ) || [];
+
+ const confidence =
+ matchedPackages.length * 40 +
+ matchedEnvVars.length * 30 +
+ matchedFiles.length * 30;
+
+ if (confidence > 0) {
+ detectedServices.push({
+ name: sig.name,
+ category: sig.category,
+ confidence: Math.min(confidence, 100),
+ matchedPackages,
+ matchedEnvVars,
+ matchedFiles,
+ docs: sig.docs,
+ requiredEnvVars: sig.indicators.envVars || [],
+ });
+ }
+ }
+
+ return detectedServices.sort((a, b) => b.confidence - a.confidence);
+}
+
+module.exports = { scanServices };
diff --git a/env-agent-finder/src/server.js b/env-agent-finder/src/server.js
new file mode 100644
index 0000000..fcc1a01
--- /dev/null
+++ b/env-agent-finder/src/server.js
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+const url = require("url");
+const { scanProject } = require("./index");
+
+const PORT = process.env.EAF_PORT || 4800;
+
+function parseBody(req) {
+ return new Promise((resolve, reject) => {
+ let body = "";
+ req.on("data", (chunk) => (body += chunk));
+ req.on("end", () => {
+ try {
+ resolve(JSON.parse(body));
+ } catch {
+ resolve({});
+ }
+ });
+ req.on("error", reject);
+ });
+}
+
+function json(res, data, status = 200) {
+ res.writeHead(status, {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ });
+ res.end(JSON.stringify(data));
+}
+
+function serveFile(res, filePath, contentType) {
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ res.writeHead(200, { "Content-Type": contentType });
+ res.end(content);
+ } catch {
+ res.writeHead(404);
+ res.end("Not found");
+ }
+}
+
+function generateEnvFile(envVars, services, userValues = {}) {
+ const lines = [];
+ lines.push("# Generated by env-agent-finder");
+ lines.push(`# ${new Date().toISOString()}`);
+ lines.push("");
+
+ const varServiceMap = {};
+ for (const svc of services) {
+ for (const v of svc.requiredEnvVars || []) {
+ varServiceMap[v] = svc.name;
+ }
+ }
+
+ const grouped = new Map();
+ for (const v of envVars) {
+ const svc = varServiceMap[v.name] || v.service || "Other";
+ if (!grouped.has(svc)) grouped.set(svc, []);
+ grouped.get(svc).push(v);
+ }
+
+ for (const [service, vars] of grouped) {
+ lines.push(`# ${service}`);
+ for (const v of vars) {
+ const value = userValues[v.name] || v.value || "";
+ lines.push(`${v.name}=${value}`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n");
+}
+
+const server = http.createServer(async (req, res) => {
+ const parsed = url.parse(req.url, true);
+ const pathname = parsed.pathname;
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ });
+ res.end();
+ return;
+ }
+
+ if (pathname === "/" && req.method === "GET") {
+ serveFile(res, path.join(__dirname, "app.html"), "text/html");
+ return;
+ }
+
+ if (pathname === "/api/scan" && req.method === "POST") {
+ const body = await parseBody(req);
+ const targetDir = body.path;
+
+ if (!targetDir) {
+ return json(res, { error: "Missing 'path' field" }, 400);
+ }
+
+ const resolved = path.resolve(targetDir);
+ if (!fs.existsSync(resolved)) {
+ return json(res, { error: `Directory not found: ${resolved}` }, 404);
+ }
+
+ try {
+ const report = await scanProject(resolved);
+ return json(res, report);
+ } catch (err) {
+ return json(res, { error: err.message }, 500);
+ }
+ }
+
+ if (pathname === "/api/generate" && req.method === "POST") {
+ const body = await parseBody(req);
+ const { envVars, services, userValues } = body;
+
+ if (!envVars) {
+ return json(res, { error: "Missing scan data" }, 400);
+ }
+
+ const content = generateEnvFile(envVars, services || [], userValues || {});
+ return json(res, { content, filename: ".env.local" });
+ }
+
+ if (pathname === "/api/save" && req.method === "POST") {
+ const body = await parseBody(req);
+ const { targetDir, envVars, services, userValues } = body;
+
+ if (!targetDir || !envVars) {
+ return json(res, { error: "Missing required fields" }, 400);
+ }
+
+ const resolved = path.resolve(targetDir);
+ const envPath = path.join(resolved, ".env.local");
+ const content = generateEnvFile(envVars, services || [], userValues || {});
+
+ try {
+ fs.writeFileSync(envPath, content, "utf-8");
+ return json(res, { success: true, path: envPath });
+ } catch (err) {
+ return json(res, { error: err.message }, 500);
+ }
+ }
+
+ res.writeHead(404);
+ res.end("Not found");
+});
+
+server.listen(PORT, () => {
+ console.log("");
+ console.log("═".repeat(50));
+ console.log(" ENV AGENT FINDER — Web App");
+ console.log("═".repeat(50));
+ console.log("");
+ console.log(` 🌐 Open in browser: http://localhost:${PORT}`);
+ console.log("");
+ console.log(" Press Ctrl+C to stop");
+ console.log("═".repeat(50));
+ console.log("");
+});
diff --git a/env-agent-finder/vercel.json b/env-agent-finder/vercel.json
new file mode 100644
index 0000000..245b0d1
--- /dev/null
+++ b/env-agent-finder/vercel.json
@@ -0,0 +1,8 @@
+{
+ "buildCommand": "",
+ "outputDirectory": "public",
+ "framework": null,
+ "rewrites": [
+ { "source": "/(.*)", "destination": "/index.html" }
+ ]
+}
diff --git a/stubs/stack-auth-nextjs/index.d.ts b/stubs/stack-auth-nextjs/index.d.ts
new file mode 100644
index 0000000..48269a4
--- /dev/null
+++ b/stubs/stack-auth-nextjs/index.d.ts
@@ -0,0 +1,9 @@
+interface StackUser {
+ id: string;
+ displayName: string;
+ primaryEmail: string;
+ activeOrganizationId: string;
+}
+
+export function useUser(): StackUser;
+export function currentUser(): Promise;
diff --git a/stubs/stack-auth-nextjs/index.js b/stubs/stack-auth-nextjs/index.js
new file mode 100644
index 0000000..7f295e8
--- /dev/null
+++ b/stubs/stack-auth-nextjs/index.js
@@ -0,0 +1,18 @@
+"use client";
+
+const mockUser = {
+ id: "00000000-0000-0000-0000-000000000002",
+ displayName: "Dev User",
+ primaryEmail: "dev@taskflow.local",
+ activeOrganizationId: "00000000-0000-0000-0000-000000000001",
+};
+
+function useUser() {
+ return mockUser;
+}
+
+async function currentUser() {
+ return mockUser;
+}
+
+module.exports = { useUser, currentUser };
diff --git a/stubs/stack-auth-nextjs/package.json b/stubs/stack-auth-nextjs/package.json
new file mode 100644
index 0000000..ccc81de
--- /dev/null
+++ b/stubs/stack-auth-nextjs/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@stack-auth/nextjs",
+ "version": "0.0.1",
+ "main": "./index.js",
+ "types": "./index.d.ts",
+ "exports": {
+ ".": {
+ "react-server": "./server.js",
+ "default": "./index.js"
+ }
+ }
+}
diff --git a/stubs/stack-auth-nextjs/server.js b/stubs/stack-auth-nextjs/server.js
new file mode 100644
index 0000000..f746145
--- /dev/null
+++ b/stubs/stack-auth-nextjs/server.js
@@ -0,0 +1,16 @@
+const mockUser = {
+ id: "00000000-0000-0000-0000-000000000002",
+ displayName: "Dev User",
+ primaryEmail: "dev@taskflow.local",
+ activeOrganizationId: "00000000-0000-0000-0000-000000000001",
+};
+
+async function currentUser() {
+ return mockUser;
+}
+
+function useUser() {
+ return mockUser;
+}
+
+module.exports = { currentUser, useUser };