lovelace v0.1 — lace-clad lead tracker for Glitch University outreach
Standalone Python CLI + SQLite (stdlib only). Tracks creator leads, which letters went to which address, replies, and the funnel (prospect → … → live). Commands: init/add/list/show/set/note/sent/reply/next/stats/export. Lead data (emails/PII) stays local and is gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
# Lead data lives in SQLite and is NOT version-controlled (it holds emails / PII).
|
||||
*.db
|
||||
.lovelace/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
@@ -0,0 +1,53 @@
|
||||
# lovelace
|
||||
|
||||
*A beautiful, enticing, lace-clad system for tracking creator leads — and the
|
||||
letters we send to lure them into putting their content on Glitch University.*
|
||||
|
||||
Named for Ada: the first programmer, in lace. **lovelace** is the Phase 3 engine
|
||||
of the WP6 influencer campaign — it remembers **who we wrote to, at which address,
|
||||
what we said, who wrote back, and how close each creator is to a live subtree.**
|
||||
|
||||
No server, no dependencies — Python stdlib + a SQLite ledger. The lead data
|
||||
(emails, PII) stays local and is never committed.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e . # then `lovelace ...`
|
||||
# or, without installing:
|
||||
python3 -m lovelace ...
|
||||
```
|
||||
|
||||
The ledger lives at `~/.lovelace/lovelace.db` (override with `LOVELACE_DB`).
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
lovelace init # open the book
|
||||
|
||||
lovelace add --name "Jane Deepdive" --platform youtube \
|
||||
--topic "heterodox economics" --email jane@example.com \
|
||||
--difficulty low --status shortlist --fit "long-form, paid audience"
|
||||
|
||||
lovelace list # the whole book
|
||||
lovelace list --status shortlist # filtered
|
||||
lovelace next # who to approach next (easiest graft first)
|
||||
|
||||
lovelace sent 1 --subject "Not a pitch..." --variant quince-A # log a letter
|
||||
lovelace reply 1 "Intrigued — what's the split?" --disposition interested
|
||||
lovelace show 1 # full history for one lead
|
||||
|
||||
lovelace stats # the funnel
|
||||
lovelace export --csv leads.csv # hand-off / backup
|
||||
```
|
||||
|
||||
## The funnel
|
||||
|
||||
`prospect → longlist → shortlist → contacted → replied → onboarding → live`
|
||||
(plus `declined`). `sent` auto-advances a lead to *contacted*; `reply` to *replied*.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Phase 3 (this):** lead DB + send/reply tracking + funnel. ✅
|
||||
- **Phase 4:** actually send the emails and handle replies — lovelace already
|
||||
records the templates/variants so attribution is ready.
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from lovelace.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""lovelace — a lace-clad CLI for tracking creator leads and the letters sent to them.
|
||||
|
||||
Ada would approve: the first programmer, dressed in lace. We track who we wrote to,
|
||||
what we said, who wrote back, and how close each creator is to a live subtree.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
|
||||
from . import db
|
||||
|
||||
# ── lace ────────────────────────────────────────────────────────────────────
|
||||
_TTY = sys.stdout.isatty()
|
||||
_C = {"grey": "90", "red": "31", "green": "32", "yellow": "33", "blue": "34",
|
||||
"magenta": "35", "cyan": "36", "bgreen": "92", "bold": "1", "dim": "2"}
|
||||
|
||||
|
||||
def c(s, *codes):
|
||||
if not _TTY or not codes:
|
||||
return s
|
||||
return "\033[" + ";".join(_C[x] for x in codes) + "m" + str(s) + "\033[0m"
|
||||
|
||||
|
||||
STATUS_COLOR = {
|
||||
"prospect": "grey", "longlist": "blue", "shortlist": "cyan",
|
||||
"contacted": "yellow", "replied": "magenta", "onboarding": "green",
|
||||
"live": "bgreen", "declined": "red",
|
||||
}
|
||||
|
||||
|
||||
def st(status):
|
||||
return c(status or "", STATUS_COLOR.get(status, "grey"))
|
||||
|
||||
|
||||
def padc(text, width, *codes):
|
||||
"""Colorize then pad to a visible width (padding ignores ANSI codes)."""
|
||||
text = (text or "")[:width]
|
||||
return c(text, *codes) + " " * max(0, width - len(text))
|
||||
|
||||
|
||||
def banner():
|
||||
print(c(" ╭─◦❀◦───────────────────◦❀◦─╮", "magenta"))
|
||||
print(c(" │", "magenta") + c(" L O V E L A C E", "bold") + c(" │", "magenta"))
|
||||
print(c(" │", "magenta") + c(" lead tracking, lace-clad", "dim") + c(" │", "magenta"))
|
||||
print(c(" ╰─◦❀◦───────────────────◦❀◦─╯", "magenta"))
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────--
|
||||
def get_lead(conn, lead_id):
|
||||
row = conn.execute("SELECT * FROM leads WHERE id=?", (lead_id,)).fetchone()
|
||||
if not row:
|
||||
sys.exit(c(f"No lead #{lead_id}. She is not in the book.", "red"))
|
||||
return row
|
||||
|
||||
|
||||
def touch(conn, lead_id):
|
||||
conn.execute("UPDATE leads SET updated_at=? WHERE id=?", (db.now(), lead_id))
|
||||
|
||||
|
||||
# ── commands ──────────────────────────────────────────────────────────────--
|
||||
def cmd_init(args):
|
||||
db.init_db()
|
||||
banner()
|
||||
print(c(" Book opened.", "green"), f"Ledger at {c(db.DB_PATH, 'dim')}")
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
conn = db.init_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO leads(name,handle,email,platform,topic,audience,fit,difficulty,status,source,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(args.name, args.handle, args.email, args.platform, args.topic, args.audience,
|
||||
args.fit, args.difficulty, args.status, args.source, db.now(), db.now()))
|
||||
conn.commit()
|
||||
print(c(f" ✶ #{cur.lastrowid}", "bgreen"), c(args.name, "bold"),
|
||||
"added as", st(args.status))
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
conn = db.init_db()
|
||||
q, params = "SELECT * FROM leads", []
|
||||
where = []
|
||||
if args.status:
|
||||
where.append("status=?"); params.append(args.status)
|
||||
if args.difficulty:
|
||||
where.append("difficulty=?"); params.append(args.difficulty)
|
||||
if where:
|
||||
q += " WHERE " + " AND ".join(where)
|
||||
q += " ORDER BY id"
|
||||
rows = conn.execute(q, params).fetchall()
|
||||
if not rows:
|
||||
print(c(" The book is empty. Go find someone worth grafting.", "dim"))
|
||||
return
|
||||
print(c(" id name status diff platform email", "dim"))
|
||||
print(c(" ─── ───────────────────── ─────────── ────── ─────────────── ─────────────────", "dim"))
|
||||
for r in rows:
|
||||
print(" " + padc(str(r["id"]), 5, "bold") +
|
||||
padc(r["name"], 23) +
|
||||
padc(r["status"], 13, STATUS_COLOR.get(r["status"], "grey")) +
|
||||
padc(r["difficulty"], 8) +
|
||||
padc(r["platform"], 17) +
|
||||
(r["email"] or ""))
|
||||
print(c(f"\n {len(rows)} lead(s).", "dim"))
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
conn = db.init_db()
|
||||
r = get_lead(conn, args.id)
|
||||
print()
|
||||
print(" " + c(f"#{r['id']} {r['name']}", "bold") + f" {st(r['status'])}")
|
||||
for label, key in [("handle", "handle"), ("email", "email"), ("platform", "platform"),
|
||||
("topic", "topic"), ("audience", "audience"), ("difficulty", "difficulty"),
|
||||
("source", "source"), ("fit", "fit")]:
|
||||
if r[key]:
|
||||
print(f" {c(label + ':', 'dim'):<14} {r[key]}")
|
||||
notes = conn.execute("SELECT * FROM notes WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||
if notes:
|
||||
print(c("\n notes", "dim"))
|
||||
for n in notes:
|
||||
print(f" {c(n['created_at'], 'dim')} {n['body']}")
|
||||
out = conn.execute("SELECT * FROM outreach WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||
if out:
|
||||
print(c("\n letters sent", "dim"))
|
||||
for o in out:
|
||||
v = f" [{o['variant']}]" if o["variant"] else ""
|
||||
print(f" {c(o['sent_at'], 'dim')} → {c(o['to_address'] or '?', 'yellow')}{v} \"{o['subject'] or ''}\"")
|
||||
rep = conn.execute("SELECT * FROM replies WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||
if rep:
|
||||
print(c("\n replies", "dim"))
|
||||
for rp in rep:
|
||||
d = c(rp["disposition"] or "", "magenta")
|
||||
print(f" {c(rp['received_at'], 'dim')} {d} {(rp['body'] or '')[:80]}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_set(args):
|
||||
conn = db.init_db()
|
||||
get_lead(conn, args.id)
|
||||
fields = {k: getattr(args, k) for k in
|
||||
("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source")
|
||||
if getattr(args, k) is not None}
|
||||
if args.status and args.status not in db.STATUSES:
|
||||
sys.exit(c(f"Unknown status '{args.status}'. Pick from: {', '.join(db.STATUSES)}", "red"))
|
||||
if not fields:
|
||||
sys.exit(c("Nothing to set. Pass --status / --email / etc.", "red"))
|
||||
sets = ", ".join(f"{k}=?" for k in fields) + ", updated_at=?"
|
||||
conn.execute(f"UPDATE leads SET {sets} WHERE id=?", (*fields.values(), db.now(), args.id))
|
||||
conn.commit()
|
||||
print(c(f" #{args.id} updated:", "green"), ", ".join(f"{k}={v}" for k, v in fields.items()))
|
||||
|
||||
|
||||
def cmd_note(args):
|
||||
conn = db.init_db()
|
||||
get_lead(conn, args.id)
|
||||
conn.execute("INSERT INTO notes(lead_id,body,created_at) VALUES(?,?,?)",
|
||||
(args.id, args.text, db.now()))
|
||||
touch(conn, args.id)
|
||||
conn.commit()
|
||||
print(c(f" noted on #{args.id}.", "green"))
|
||||
|
||||
|
||||
def cmd_sent(args):
|
||||
conn = db.init_db()
|
||||
lead = get_lead(conn, args.id)
|
||||
to = args.to or lead["email"]
|
||||
if not to:
|
||||
sys.exit(c("No address. Pass --to or set the lead's email first.", "red"))
|
||||
conn.execute(
|
||||
"INSERT INTO outreach(lead_id,channel,to_address,subject,variant,template,sent_at,status,notes)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(args.id, args.channel, to, args.subject, args.variant, args.template, db.now(), "sent", args.notes))
|
||||
# advance the funnel if this is the first contact
|
||||
if lead["status"] in ("prospect", "longlist", "shortlist"):
|
||||
conn.execute("UPDATE leads SET status='contacted' WHERE id=?", (args.id,))
|
||||
touch(conn, args.id)
|
||||
conn.commit()
|
||||
print(c(f" ✉ letter logged", "yellow"), "to", c(to, "bold"),
|
||||
f"(#{args.id} {lead['name']})", "→", st("contacted"))
|
||||
|
||||
|
||||
def cmd_reply(args):
|
||||
conn = db.init_db()
|
||||
lead = get_lead(conn, args.id)
|
||||
conn.execute(
|
||||
"INSERT INTO replies(lead_id,outreach_id,received_at,disposition,body,handled)"
|
||||
" VALUES(?,?,?,?,?,0)",
|
||||
(args.id, args.outreach, db.now(), args.disposition, args.text))
|
||||
if lead["status"] in ("contacted", "longlist", "shortlist", "prospect"):
|
||||
conn.execute("UPDATE leads SET status='replied' WHERE id=?", (args.id,))
|
||||
touch(conn, args.id)
|
||||
conn.commit()
|
||||
d = c(args.disposition, "magenta") if args.disposition else ""
|
||||
print(c(f" ✎ reply logged", "magenta"), f"from #{args.id} {lead['name']}", d, "→", st("replied"))
|
||||
|
||||
|
||||
def cmd_next(args):
|
||||
conn = db.init_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads WHERE status IN ('shortlist','longlist')"
|
||||
" AND id NOT IN (SELECT lead_id FROM outreach)"
|
||||
" ORDER BY CASE difficulty WHEN 'low' THEN 0 WHEN 'med' THEN 1 WHEN 'high' THEN 2 ELSE 3 END,"
|
||||
" CASE status WHEN 'shortlist' THEN 0 ELSE 1 END, id LIMIT ?", (args.limit,)).fetchall()
|
||||
if not rows:
|
||||
print(c(" No one waiting. Longlist some creators, or go plant a subtree.", "dim"))
|
||||
return
|
||||
print(c(" next to approach (easiest graft first):", "dim"))
|
||||
for r in rows:
|
||||
print(" " + c(f"#{r['id']}", "bold") + f" {r['name']:<22} {st(r['status'])} "
|
||||
+ c(f"graft:{r['difficulty']}", "dim") + f" {r['platform'] or ''}")
|
||||
|
||||
|
||||
def cmd_stats(args):
|
||||
conn = db.init_db()
|
||||
banner()
|
||||
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||
if not total:
|
||||
print(c("\n No leads yet. The lace is laid; now bring the creators.", "dim"))
|
||||
return
|
||||
print(c("\n funnel", "dim"))
|
||||
counts = {s: 0 for s in db.STATUSES}
|
||||
for row in conn.execute("SELECT status, COUNT(*) n FROM leads GROUP BY status"):
|
||||
counts[row["status"]] = row["n"]
|
||||
width = 28
|
||||
for s in db.STATUSES:
|
||||
n = counts.get(s, 0)
|
||||
bar = "▰" * min(n, width)
|
||||
print(f" {st(s):<19} {c(str(n), 'bold'):>3} {c(bar, STATUS_COLOR.get(s, 'grey'))}")
|
||||
sent = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM outreach").fetchone()[0]
|
||||
replied = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM replies").fetchone()[0]
|
||||
rate = (100.0 * replied / sent) if sent else 0.0
|
||||
print(c("\n campaign", "dim"))
|
||||
print(f" {c('total leads', 'dim'):<22} {total}")
|
||||
print(f" {c('contacted', 'dim'):<22} {sent}")
|
||||
print(f" {c('replied', 'dim'):<22} {replied} ({c(f'{rate:.0f}%', 'bold')} response)")
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
conn = db.init_db()
|
||||
rows = conn.execute("SELECT * FROM leads ORDER BY id").fetchall()
|
||||
cols = ["id", "name", "handle", "email", "platform", "topic", "audience",
|
||||
"fit", "difficulty", "status", "source", "created_at", "updated_at"]
|
||||
out = open(args.csv, "w", newline="") if args.csv else sys.stdout
|
||||
w = csv.writer(out)
|
||||
w.writerow(cols)
|
||||
for r in rows:
|
||||
w.writerow([r[col] for col in cols])
|
||||
if args.csv:
|
||||
out.close()
|
||||
print(c(f" exported {len(rows)} lead(s) → {args.csv}", "green"))
|
||||
|
||||
|
||||
# ── parser ────────────────────────────────────────────────────────────────--
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(prog="lovelace", description="Lace-clad lead tracking for Glitch University outreach.")
|
||||
sub = p.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("init", help="Create the ledger.").set_defaults(func=cmd_init)
|
||||
|
||||
a = sub.add_parser("add", help="Add a lead.")
|
||||
a.add_argument("--name", required=True)
|
||||
for f in ("handle", "email", "platform", "topic", "audience", "fit", "source"):
|
||||
a.add_argument(f"--{f}")
|
||||
a.add_argument("--difficulty", default="unknown", choices=["low", "med", "high", "unknown"])
|
||||
a.add_argument("--status", default="prospect", choices=db.STATUSES)
|
||||
a.set_defaults(func=cmd_add)
|
||||
|
||||
li = sub.add_parser("list", help="List leads.")
|
||||
li.add_argument("--status", choices=db.STATUSES)
|
||||
li.add_argument("--difficulty", choices=["low", "med", "high", "unknown"])
|
||||
li.set_defaults(func=cmd_list)
|
||||
|
||||
sh = sub.add_parser("show", help="Show one lead in full.")
|
||||
sh.add_argument("id", type=int)
|
||||
sh.set_defaults(func=cmd_show)
|
||||
|
||||
se = sub.add_parser("set", help="Update lead fields.")
|
||||
se.add_argument("id", type=int)
|
||||
for f in ("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source"):
|
||||
se.add_argument(f"--{f}")
|
||||
se.set_defaults(func=cmd_set)
|
||||
|
||||
no = sub.add_parser("note", help="Add a note to a lead.")
|
||||
no.add_argument("id", type=int)
|
||||
no.add_argument("text")
|
||||
no.set_defaults(func=cmd_note)
|
||||
|
||||
sn = sub.add_parser("sent", help="Log a letter sent to a lead's address.")
|
||||
sn.add_argument("id", type=int)
|
||||
sn.add_argument("--to", help="Address (defaults to the lead's email).")
|
||||
sn.add_argument("--subject")
|
||||
sn.add_argument("--variant", help="A/B variant tag (subject/sender).")
|
||||
sn.add_argument("--template")
|
||||
sn.add_argument("--channel", default="email")
|
||||
sn.add_argument("--notes")
|
||||
sn.set_defaults(func=cmd_sent)
|
||||
|
||||
rp = sub.add_parser("reply", help="Log a reply from a lead.")
|
||||
rp.add_argument("id", type=int)
|
||||
rp.add_argument("text")
|
||||
rp.add_argument("--disposition", choices=["interested", "maybe", "question", "declined"])
|
||||
rp.add_argument("--outreach", type=int, help="Which outreach id this answers.")
|
||||
rp.set_defaults(func=cmd_reply)
|
||||
|
||||
nx = sub.add_parser("next", help="Who to approach next (easiest graft first).")
|
||||
nx.add_argument("--limit", type=int, default=10)
|
||||
nx.set_defaults(func=cmd_next)
|
||||
|
||||
sub.add_parser("stats", help="Campaign funnel.").set_defaults(func=cmd_stats)
|
||||
|
||||
ex = sub.add_parser("export", help="Export leads to CSV.")
|
||||
ex.add_argument("--csv", help="File path (default: stdout).")
|
||||
ex.set_defaults(func=cmd_export)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not getattr(args, "command", None):
|
||||
banner()
|
||||
parser.print_help()
|
||||
return 0
|
||||
args.func(args)
|
||||
return 0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""lovelace data layer — SQLite, stdlib only."""
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DEFAULT_DIR = pathlib.Path(os.environ.get("LOVELACE_HOME", pathlib.Path.home() / ".lovelace"))
|
||||
DB_PATH = pathlib.Path(os.environ.get("LOVELACE_DB", DEFAULT_DIR / "lovelace.db"))
|
||||
|
||||
# Funnel stages, in order. A lead's status is one of these.
|
||||
STATUSES = [
|
||||
"prospect", # spotted, not yet vetted
|
||||
"longlist", # worth approaching
|
||||
"shortlist", # top picks for the launch cohort
|
||||
"contacted", # a letter has gone out
|
||||
"replied", # they wrote back
|
||||
"onboarding", # grafting their subtree
|
||||
"live", # subtree is up
|
||||
"declined", # not interested / not a fit
|
||||
]
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
handle TEXT,
|
||||
email TEXT,
|
||||
platform TEXT,
|
||||
topic TEXT,
|
||||
audience TEXT,
|
||||
fit TEXT,
|
||||
difficulty TEXT DEFAULT 'unknown',
|
||||
status TEXT DEFAULT 'prospect',
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outreach (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
channel TEXT DEFAULT 'email',
|
||||
to_address TEXT,
|
||||
subject TEXT,
|
||||
variant TEXT,
|
||||
template TEXT,
|
||||
sent_at TEXT,
|
||||
status TEXT DEFAULT 'sent',
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
outreach_id INTEGER REFERENCES outreach(id) ON DELETE SET NULL,
|
||||
received_at TEXT,
|
||||
disposition TEXT,
|
||||
body TEXT,
|
||||
handled INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
body TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
def connect():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = connect()
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
return conn
|
||||
@@ -0,0 +1,11 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="lovelace",
|
||||
version="0.1.0",
|
||||
description="Lace-clad CLI for tracking creator leads and outreach for Glitch University.",
|
||||
packages=find_packages(),
|
||||
install_requires=[], # stdlib only
|
||||
entry_points={"console_scripts": ["lovelace=lovelace.cli:main"]},
|
||||
python_requires=">=3.9",
|
||||
)
|
||||
Reference in New Issue
Block a user