/** * pi-leads — Terminal CRM / Outreach Pipeline Manager * Track prospects, manage follow-ups, draft messages. * The first tool that MAKES MONEY. * * /leads add → add a lead * /leads list [--stage=X] → list leads * /leads show → lead details * /leads update --stage=X → update stage * /leads followup → who needs attention * /leads pipeline → pipeline visualization * /leads draft → generate outreach context * /leads stats → conversion metrics * /leads export [csv|json] → export data */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; const SAVE_DIR = join(homedir(), ".pi", "leads"); const DATA_FILE = join(SAVE_DIR, "leads.json"); const RST = "\x1b[0m", B = "\x1b[1m", D = "\x1b[2m"; const GREEN = "\x1b[32m", RED = "\x1b[31m", YELLOW = "\x1b[33m", CYAN = "\x1b[36m", MAGENTA = "\x1b[35m"; type Stage = "prospect" | "qualified" | "contacted" | "negotiation" | "closed" | "lost"; const STAGES: Stage[] = ["prospect", "qualified", "contacted", "negotiation", "closed", "lost"]; const STAGE_COLORS: Record = { prospect: CYAN, qualified: YELLOW, contacted: MAGENTA, negotiation: B, closed: GREEN, lost: RED, }; interface HistoryEntry { date: string; action: string; detail: string } interface Lead { id: string; name: string; email: string; company: string; stage: Stage; value: number; notes: string[]; history: HistoryEntry[]; createdAt: string; updatedAt: string; nextFollowUp: string; } function loadLeads(): Lead[] { mkdirSync(SAVE_DIR, { recursive: true }); try { return JSON.parse(readFileSync(DATA_FILE, "utf-8")); } catch { return []; } } function saveLeads(leads: Lead[]) { mkdirSync(SAVE_DIR, { recursive: true }); writeFileSync(DATA_FILE, JSON.stringify(leads, null, 2)); } function nextId(leads: Lead[]): string { const max = leads.reduce((m, l) => Math.max(m, parseInt(l.id.replace("lead-", "")) || 0), 0); return `lead-${String(max + 1).padStart(3, "0")}`; } function daysSince(date: string): number { return Math.floor((Date.now() - new Date(date).getTime()) / 86400000); } function parseArgs(args: string): { positional: string[]; flags: Record } { const positional: string[] = []; const flags: Record = {}; const parts = args.match(/(?:[^\s"]+|"[^"]*")+/g) || []; for (const p of parts) { if (p.startsWith("--")) { const [k, ...v] = p.slice(2).split("="); flags[k] = v.join("=").replace(/^"|"$/g, "") || "true"; } else { positional.push(p.replace(/^"|"$/g, "")); } } return { positional, flags }; } export default function piLeads(pi: ExtensionAPI) { pi.registerCommand("leads", { description: "CRM pipeline. /leads [add|list|show|update|followup|pipeline|draft|stats|export]", handler: async (args, ctx) => { const { positional, flags } = parseArgs(args.trim()); const sub = positional[0]?.toLowerCase() || "help"; switch (sub) { case "add": { const name = positional[1]; const email = positional[2] || ""; if (!name) { ctx.ui.notify("Usage: /leads add [email] [--company=X] [--stage=X] [--value=X]", "error"); return; } const leads = loadLeads(); const lead: Lead = { id: nextId(leads), name, email, company: flags.company || "", stage: (flags.stage as Stage) || "prospect", value: parseFloat(flags.value || "0") || 0, notes: flags.notes ? [flags.notes] : [], history: [{ date: new Date().toISOString(), action: "created", detail: `Added as ${flags.stage || "prospect"}` }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nextFollowUp: "", }; leads.push(lead); saveLeads(leads); ctx.ui.notify(`${GREEN}✓${RST} Added ${B}${lead.name}${RST} (${lead.id}) → ${STAGE_COLORS[lead.stage]}${lead.stage}${RST}`, "info"); break; } case "list": case "ls": { const leads = loadLeads(); const stageFilter = flags.stage as Stage | undefined; const filtered = stageFilter ? leads.filter(l => l.stage === stageFilter) : leads; if (filtered.length === 0) { ctx.ui.notify("No leads found.", "info"); return; } let out = `${B}${CYAN}Leads${RST} (${filtered.length})\n\n`; out += ` ${"ID".padEnd(10)} ${"Name".padEnd(20)} ${"Company".padEnd(15)} ${"Stage".padEnd(13)} ${"Days".padStart(4)}\n`; out += ` ${D}${"─".repeat(65)}${RST}\n`; for (const l of filtered) { const days = daysSince(l.updatedAt); const stale = days > 3 ? `${RED}${days}${RST}` : `${D}${days}${RST}`; out += ` ${D}${l.id.padEnd(10)}${RST} ${l.name.padEnd(20).slice(0, 20)} ${D}${(l.company || "—").padEnd(15).slice(0, 15)}${RST} ${STAGE_COLORS[l.stage]}${l.stage.padEnd(13)}${RST} ${stale}\n`; } ctx.ui.notify(out, "info"); break; } case "show": { const id = positional[1]; if (!id) { ctx.ui.notify("Usage: /leads show ", "error"); return; } const leads = loadLeads(); const lead = leads.find(l => l.id === id || l.name.toLowerCase().includes(id.toLowerCase())); if (!lead) { ctx.ui.notify(`Lead not found: ${id}`, "error"); return; } let out = `${B}${CYAN}${lead.name}${RST} (${lead.id})\n`; out += ` ${D}Email:${RST} ${lead.email || "—"}\n`; out += ` ${D}Company:${RST} ${lead.company || "—"}\n`; out += ` ${D}Stage:${RST} ${STAGE_COLORS[lead.stage]}${lead.stage}${RST}\n`; out += ` ${D}Value:${RST} ${lead.value ? `$${lead.value.toLocaleString()}` : "—"}\n`; out += ` ${D}Created:${RST} ${lead.createdAt.slice(0, 10)} (${daysSince(lead.createdAt)}d ago)\n`; out += ` ${D}Updated:${RST} ${lead.updatedAt.slice(0, 10)} (${daysSince(lead.updatedAt)}d ago)\n`; if (lead.notes.length > 0) { out += `\n ${B}Notes:${RST}\n`; for (const n of lead.notes) out += ` • ${n}\n`; } if (lead.history.length > 0) { out += `\n ${B}History:${RST}\n`; for (const h of lead.history.slice(-5)) out += ` ${D}${h.date.slice(0, 10)}${RST} ${h.action}: ${h.detail}\n`; } ctx.ui.notify(out, "info"); break; } case "update": { const id = positional[1]; if (!id) { ctx.ui.notify("Usage: /leads update --stage=X [--notes='...'] [--value=X]", "error"); return; } const leads = loadLeads(); const lead = leads.find(l => l.id === id || l.name.toLowerCase().includes(id.toLowerCase())); if (!lead) { ctx.ui.notify(`Lead not found: ${id}`, "error"); return; } const changes: string[] = []; if (flags.stage && STAGES.includes(flags.stage as Stage)) { const old = lead.stage; lead.stage = flags.stage as Stage; changes.push(`stage: ${old} → ${lead.stage}`); lead.history.push({ date: new Date().toISOString(), action: "stage_change", detail: `${old} → ${lead.stage}` }); } if (flags.notes) { lead.notes.push(flags.notes); changes.push("note added"); } if (flags.value) { lead.value = parseFloat(flags.value); changes.push(`value: $${lead.value}`); } if (flags.followup) { lead.nextFollowUp = flags.followup; changes.push(`follow-up: ${flags.followup}`); } lead.updatedAt = new Date().toISOString(); saveLeads(leads); ctx.ui.notify(`${GREEN}✓${RST} Updated ${B}${lead.name}${RST}: ${changes.join(", ")}`, "info"); break; } case "followup": case "follow": { const leads = loadLeads(); const stale = leads.filter(l => !["closed", "lost"].includes(l.stage) && daysSince(l.updatedAt) >= 3 ).sort((a, b) => daysSince(b.updatedAt) - daysSince(a.updatedAt)); if (stale.length === 0) { ctx.ui.notify(`${GREEN}✓${RST} All leads are up to date!`, "info"); return; } let out = `${B}${RED}⚠ ${stale.length} leads need follow-up${RST}\n\n`; for (const l of stale) { const days = daysSince(l.updatedAt); out += ` ${RED}${days}d${RST} ${l.id} ${B}${l.name}${RST} ${D}(${l.company || "—"})${RST} → ${STAGE_COLORS[l.stage]}${l.stage}${RST}\n`; } ctx.ui.notify(out, "info"); break; } case "pipeline": case "pipe": { const leads = loadLeads(); let out = `${B}${CYAN}Pipeline${RST}\n\n`; const maxCount = Math.max(...STAGES.map(s => leads.filter(l => l.stage === s).length), 1); for (const stage of STAGES) { const count = leads.filter(l => l.stage === stage).length; const value = leads.filter(l => l.stage === stage).reduce((s, l) => s + l.value, 0); const barLen = Math.round((count / maxCount) * 20); const color = STAGE_COLORS[stage]; const valueStr = value > 0 ? ` ${D}$${value.toLocaleString()}${RST}` : ""; out += ` ${color}${stage.padEnd(13)}${RST} ${color}${"█".repeat(barLen)}${RST} ${count}${valueStr}\n`; } const total = leads.length; const totalValue = leads.reduce((s, l) => s + l.value, 0); out += `\n ${D}Total: ${total} leads`; if (totalValue > 0) out += ` | Pipeline value: $${totalValue.toLocaleString()}`; out += RST; ctx.ui.notify(out, "info"); break; } case "draft": { const id = positional[1]; if (!id) { ctx.ui.notify("Usage: /leads draft [--type=intro|followup|proposal]", "error"); return; } const leads = loadLeads(); const lead = leads.find(l => l.id === id || l.name.toLowerCase().includes(id.toLowerCase())); if (!lead) { ctx.ui.notify(`Lead not found: ${id}`, "error"); return; } const type = flags.type || (lead.stage === "prospect" ? "intro" : "followup"); let out = `${B}${CYAN}Draft Context for ${lead.name}${RST}\n`; out += ` ${D}Type:${RST} ${type}\n\n`; out += `${B}Context for LLM:${RST}\n`; out += ` Recipient: ${lead.name}${lead.company ? ` at ${lead.company}` : ""}\n`; out += ` Email: ${lead.email}\n`; out += ` Current stage: ${lead.stage}\n`; out += ` Days since last contact: ${daysSince(lead.updatedAt)}\n`; if (lead.notes.length > 0) out += ` Notes: ${lead.notes.join("; ")}\n`; out += `\n ${D}Feed this context to the LLM with your ask to generate the ${type} draft.${RST}`; ctx.ui.notify(out, "info"); break; } case "stats": { const leads = loadLeads(); if (leads.length === 0) { ctx.ui.notify("No leads yet.", "info"); return; } const closed = leads.filter(l => l.stage === "closed").length; const lost = leads.filter(l => l.stage === "lost").length; const active = leads.filter(l => !["closed", "lost"].includes(l.stage)).length; const convRate = (closed + lost) > 0 ? (closed / (closed + lost) * 100).toFixed(1) : "—"; const totalValue = leads.filter(l => l.stage === "closed").reduce((s, l) => s + l.value, 0); const pipelineValue = leads.filter(l => !["closed", "lost"].includes(l.stage)).reduce((s, l) => s + l.value, 0); let out = `${B}${CYAN}Stats${RST}\n\n`; out += ` ${D}Total leads:${RST} ${leads.length}\n`; out += ` ${D}Active:${RST} ${GREEN}${active}${RST}\n`; out += ` ${D}Closed/Won:${RST} ${GREEN}${closed}${RST}\n`; out += ` ${D}Lost:${RST} ${RED}${lost}${RST}\n`; out += ` ${D}Win rate:${RST} ${convRate}%\n`; out += ` ${D}Closed value:${RST} ${GREEN}$${totalValue.toLocaleString()}${RST}\n`; out += ` ${D}Pipeline value:${RST} $${pipelineValue.toLocaleString()}\n`; ctx.ui.notify(out, "info"); break; } case "export": { const leads = loadLeads(); const format = positional[1]?.toLowerCase() || "json"; if (format === "csv") { const header = "id,name,email,company,stage,value,createdAt,updatedAt"; const rows = leads.map(l => `${l.id},${l.name},${l.email},${l.company},${l.stage},${l.value},${l.createdAt},${l.updatedAt}`); ctx.ui.notify(`\`\`\`csv\n${header}\n${rows.join("\n")}\n\`\`\``, "info"); } else { ctx.ui.notify(`\`\`\`json\n${JSON.stringify(leads, null, 2)}\n\`\`\``, "info"); } break; } case "delete": case "rm": { const id = positional[1]; if (!id) { ctx.ui.notify("Usage: /leads delete ", "error"); return; } const leads = loadLeads(); const idx = leads.findIndex(l => l.id === id); if (idx === -1) { ctx.ui.notify(`Lead not found: ${id}`, "error"); return; } const removed = leads.splice(idx, 1)[0]; saveLeads(leads); ctx.ui.notify(`${RED}✗${RST} Deleted ${B}${removed.name}${RST} (${removed.id})`, "info"); break; } default: { ctx.ui.notify([ `${B}${CYAN}📋 Leads — Terminal CRM${RST}`, "", ` /leads add [email] [--company=X] [--stage=X] [--value=X]`, ` /leads list [--stage=X] — list leads`, ` /leads show — lead details`, ` /leads update --stage=X — move stage`, ` /leads followup — stale leads (3+ days)`, ` /leads pipeline — funnel visualization`, ` /leads draft — generate outreach context`, ` /leads stats — conversion metrics`, ` /leads export [csv|json] — export data`, ` /leads delete — remove lead`, ].join("\n"), "info"); } } }, }); // LLM Tools pi.registerTool({ name: "leads_add", description: "Add a new lead to the CRM pipeline", parameters: Type.Object({ name: Type.String(), email: Type.Optional(Type.String()), company: Type.Optional(Type.String()), stage: Type.Optional(Type.String()), value: Type.Optional(Type.Number()), notes: Type.Optional(Type.String()), }), execute: async (p) => { const leads = loadLeads(); const lead: Lead = { id: nextId(leads), name: p.name, email: p.email || "", company: p.company || "", stage: (p.stage as Stage) || "prospect", value: p.value || 0, notes: p.notes ? [p.notes] : [], history: [{ date: new Date().toISOString(), action: "created", detail: `Added as ${p.stage || "prospect"}` }], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nextFollowUp: "", }; leads.push(lead); saveLeads(leads); return { id: lead.id, name: lead.name, stage: lead.stage }; }, }); pi.registerTool({ name: "leads_search", description: "Search leads by name, company, or stage", parameters: Type.Object({ query: Type.Optional(Type.String()), stage: Type.Optional(Type.String()) }), execute: async (p) => { const leads = loadLeads(); let filtered = leads; if (p.stage) filtered = filtered.filter(l => l.stage === p.stage); if (p.query) { const q = p.query.toLowerCase(); filtered = filtered.filter(l => l.name.toLowerCase().includes(q) || l.company.toLowerCase().includes(q) || l.email.toLowerCase().includes(q)); } return filtered.map(l => ({ id: l.id, name: l.name, company: l.company, stage: l.stage, daysSinceUpdate: daysSince(l.updatedAt) })); }, }); pi.registerTool({ name: "leads_followup", description: "Get leads that need follow-up (no activity in 3+ days)", parameters: Type.Object({}), execute: async () => { const leads = loadLeads(); return leads.filter(l => !["closed", "lost"].includes(l.stage) && daysSince(l.updatedAt) >= 3) .map(l => ({ id: l.id, name: l.name, stage: l.stage, daysSinceUpdate: daysSince(l.updatedAt) })); }, }); pi.registerTool({ name: "leads_pipeline", description: "Get pipeline summary — count and value per stage", parameters: Type.Object({}), execute: async () => { const leads = loadLeads(); return STAGES.map(s => ({ stage: s, count: leads.filter(l => l.stage === s).length, value: leads.filter(l => l.stage === s).reduce((sum, l) => sum + l.value, 0), })); }, }); }