letz-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
letz/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Letz Brain CLI — pull a campaign from the Letz platform, draft email
2
+ sequences locally with Claude, and push them back staged for review.
3
+ Nothing sends until a human clicks Activate.
4
+ """
5
+ __version__ = "0.1.0"
letz/__main__.py ADDED
@@ -0,0 +1,246 @@
1
+ """Letz Brain CLI entrypoint.
2
+
3
+ Workflow:
4
+ letz pull <campaign_id> # fetch campaign + persona + leads -> work/<id>.pull.json
5
+ # (also writes a work/<id>.ready.json scaffold to fill)
6
+ # --- generation happens HERE (Claude fills work/<id>.ready.json) ---
7
+ letz check <campaign_id> # run the pure-Python quality gauntlet (no AI)
8
+ letz push <campaign_id> # POST /sequences/import, staged PAUSED (no send)
9
+ letz status <campaign_id> # show staged enrollments / previews / sends
10
+ # review in the UI, then click Activate (or: letz push --activate)
11
+
12
+ Config:
13
+ --api base URL (default: $LETZ_API_URL or http://localhost:8000)
14
+ --work work dir (default: $LETZ_WORK or ./letz_work)
15
+ """
16
+ from __future__ import annotations
17
+ import argparse
18
+ import datetime
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+ try:
24
+ from .api import LetzAPI, LetzAPIError
25
+ from .gauntlet import audit_sequence
26
+ except ImportError: # allow running as a plain script: python cli/letz/__main__.py
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+ from letz.api import LetzAPI, LetzAPIError
29
+ from letz.gauntlet import audit_sequence
30
+
31
+
32
+ def _work_dir(args) -> str:
33
+ d = args.work or os.environ.get("LETZ_WORK") or os.path.join(os.getcwd(), "letz_work")
34
+ os.makedirs(d, exist_ok=True)
35
+ return d
36
+
37
+
38
+ def _pull_path(args, cid): return os.path.join(_work_dir(args), f"{cid}.pull.json")
39
+ def _ready_path(args, cid): return os.path.join(_work_dir(args), f"{cid}.ready.json")
40
+
41
+
42
+ def _load(path):
43
+ with open(path) as f:
44
+ return json.load(f)
45
+
46
+
47
+ def _save(path, obj):
48
+ with open(path, "w") as f:
49
+ json.dump(obj, f, indent=2, ensure_ascii=False)
50
+
51
+
52
+ # ---------------------------------------------------------------- commands ----
53
+ def cmd_pull(args):
54
+ api = LetzAPI(args.api)
55
+ camp = api.campaign(args.campaign_id)
56
+ if not camp:
57
+ print(f"✗ Campaign {args.campaign_id} not found at {args.api}")
58
+ return 2
59
+ persona = api.persona()
60
+ leads = api.leads(args.campaign_id, limit=args.limit)
61
+ pull = {
62
+ "pulled_at": datetime.datetime.utcnow().isoformat() + "Z",
63
+ "api": args.api,
64
+ "campaign": camp,
65
+ "persona": persona,
66
+ "leads": leads,
67
+ }
68
+ _save(_pull_path(args, args.campaign_id), pull)
69
+
70
+ # Auto-write a ready.json scaffold to fill in.
71
+ forbidden = persona.get("forbidden_names", "") if persona else ""
72
+ scaffold = {
73
+ "campaign_id": camp["id"],
74
+ "campaign_name": camp.get("name", ""),
75
+ "max_touches": camp.get("max_touches", 3),
76
+ "touch_intervals": camp.get("touch_intervals", []),
77
+ "forbidden_names": forbidden,
78
+ "_style_guide": camp.get("style_guide", ""),
79
+ "_strategy_brief": camp.get("strategy_brief", ""),
80
+ "_tone": camp.get("tone", "professional"),
81
+ "_from_name": camp.get("from_name", "Sam Reyes"),
82
+ "sequences": [
83
+ {
84
+ "lead_id": l.get("id"),
85
+ "_lead": {
86
+ "name": (l.get("first_name", "") + " " + l.get("last_name", "")).strip(),
87
+ "title": l.get("title", ""), "company": l.get("company", ""),
88
+ "industry": l.get("industry", ""), "email": l.get("email", ""),
89
+ },
90
+ "brief": {},
91
+ "emails": [],
92
+ }
93
+ for l in leads if l.get("id") and (l.get("email") or "").strip()
94
+ ],
95
+ }
96
+ # Don't clobber an in-progress ready file unless --force.
97
+ rp = _ready_path(args, args.campaign_id)
98
+ if os.path.exists(rp) and not args.force:
99
+ print(f"• ready scaffold already exists (kept): {rp} (use --force to overwrite)")
100
+ else:
101
+ _save(rp, scaffold)
102
+ print(f"✓ scaffold written: {rp}")
103
+
104
+ n_with_email = len(scaffold["sequences"])
105
+ print(f"✓ pulled '{camp.get('name')}' — {len(leads)} leads ({n_with_email} with email) -> {_pull_path(args, args.campaign_id)}")
106
+ print(f" Next: fill the 'emails' arrays in {rp} ({camp.get('max_touches',3)} touches each), then `letz check {args.campaign_id}`")
107
+ return 0
108
+
109
+
110
+ def cmd_check(args):
111
+ ready = _load(_ready_path(args, args.campaign_id))
112
+ forbidden = ready.get("forbidden_names", "")
113
+ expected = ready.get("max_touches")
114
+ seqs = ready.get("sequences", [])
115
+ total, ok_count, hard_total, empty = 0, 0, 0, 0
116
+ for seq in seqs:
117
+ emails = seq.get("emails", [])
118
+ if not emails:
119
+ empty += 1
120
+ continue
121
+ total += 1
122
+ res = audit_sequence(emails, forbidden, expected_steps=expected)
123
+ if res["ok"]:
124
+ ok_count += 1
125
+ label = seq.get("_lead", {}).get("name") or seq.get("lead_id", "")[:8]
126
+ for issue in res["issues"]:
127
+ hard_total += 1
128
+ print(f" ✗ {label}: {issue}")
129
+ for r in res["per_email"]:
130
+ for h in r["hard"]:
131
+ hard_total += 1
132
+ print(f" ✗ {label} step {r['step']} ({r['word_count']}w): {h}")
133
+ for s in r["soft"]:
134
+ print(f" · {label} step {r['step']}: {s}")
135
+ print(f"\n{ok_count}/{total} sequences clean | {empty} unfilled | {hard_total} hard issue(s)")
136
+ if empty:
137
+ print(f" {empty} lead(s) have no emails yet — fill them before pushing.")
138
+ return 1 if (hard_total > 0 or empty > 0 or total == 0) else 0
139
+
140
+
141
+ def cmd_push(args):
142
+ ready = _load(_ready_path(args, args.campaign_id))
143
+ items = []
144
+ for seq in ready.get("sequences", []):
145
+ emails = seq.get("emails", [])
146
+ if not emails or not seq.get("lead_id"):
147
+ continue
148
+ items.append({"lead_id": seq["lead_id"], "brief": seq.get("brief") or {}, "emails": emails})
149
+ if not items:
150
+ print("✗ nothing to push — no filled sequences in ready.json")
151
+ return 2
152
+
153
+ # Hard safety: re-run the gauntlet; never push hard-failing content.
154
+ forbidden = ready.get("forbidden_names", "")
155
+ bad = 0
156
+ for it in items:
157
+ res = audit_sequence(it["emails"], forbidden, expected_steps=ready.get("max_touches"))
158
+ if not res["ok"]:
159
+ bad += 1
160
+ if bad and not args.force:
161
+ print(f"✗ {bad} sequence(s) fail the gauntlet. Run `letz check` and fix, or use --force.")
162
+ return 1
163
+
164
+ # Push in batches so a large campaign never trips the request timeout.
165
+ # The endpoint is idempotent + atomic per request, so a failed batch can be
166
+ # safely retried by re-running push.
167
+ api = LetzAPI(args.api)
168
+ batch = max(1, args.batch)
169
+ total_imported = total_skipped = 0
170
+ last_status = None
171
+ for ci in range(0, len(items), batch):
172
+ chunk = items[ci:ci + batch]
173
+ res = api.import_sequences(args.campaign_id, chunk, stage=args.stage)
174
+ total_imported += res.get("imported", 0)
175
+ total_skipped += res.get("skipped", 0)
176
+ last_status = res.get("campaign_status")
177
+ done = min(ci + batch, len(items))
178
+ print(f" · {done}/{len(items)} pushed (imported {res.get('imported')}, skipped {res.get('skipped')})")
179
+ for s in res.get("skipped_detail", []):
180
+ print(f" skipped {s.get('lead_id','')[:8]}: {s.get('reason')}")
181
+ print(f"✓ imported {total_imported} | skipped {total_skipped} | campaign now: {last_status}")
182
+ if args.activate:
183
+ print("⚠️ --activate: flipping campaign to ACTIVE — the platform will begin sending in-window.")
184
+ a = api.activate(args.campaign_id)
185
+ print(f"✓ activated: {a.get('status', a)}")
186
+ else:
187
+ print(f" Staged paused. Review in the UI, then `letz push {args.campaign_id} --activate` or click Activate.")
188
+ return 0
189
+
190
+
191
+ def cmd_status(args):
192
+ api = LetzAPI(args.api)
193
+ camp = api.campaign(args.campaign_id) or {}
194
+ enrs = api.enrollments(args.campaign_id)
195
+ prev = api.previews(args.campaign_id)
196
+ sends = api.sends(args.campaign_id)
197
+ active = sum(1 for e in enrs if e.get("status") == "active")
198
+ sent = sum(1 for s in sends if s.get("status") == "sent")
199
+ print(f"Campaign : {camp.get('name','?')} [{camp.get('status','?')}]")
200
+ print(f"Enrollments staged : {len(enrs)} ({active} active)")
201
+ print(f"Previews (review) : {prev.get('total', 0)} ready")
202
+ print(f"Sends recorded : {len(sends)} ({sent} sent)")
203
+ return 0
204
+
205
+
206
+ def main(argv=None):
207
+ p = argparse.ArgumentParser(prog="letz", description="Letz Brain CLI — off-platform generation, persist, click send.")
208
+ p.add_argument("--api", default=os.environ.get("LETZ_API_URL", "http://localhost:8000"), help="platform base URL")
209
+ p.add_argument("--work", default=None, help="work dir (default ./letz_work)")
210
+ sub = p.add_subparsers(dest="cmd", required=True)
211
+
212
+ sp = sub.add_parser("pull", help="fetch campaign + leads, write a ready.json scaffold")
213
+ sp.add_argument("campaign_id")
214
+ sp.add_argument("--limit", type=int, default=1000)
215
+ sp.add_argument("--force", action="store_true", help="overwrite an existing ready.json scaffold")
216
+ sp.set_defaults(func=cmd_pull)
217
+
218
+ sp = sub.add_parser("check", help="run the pure-Python quality gauntlet on ready.json")
219
+ sp.add_argument("campaign_id")
220
+ sp.set_defaults(func=cmd_check)
221
+
222
+ sp = sub.add_parser("push", help="import staged sequences (paused) via POST /sequences/import")
223
+ sp.add_argument("campaign_id")
224
+ sp.add_argument("--stage", choices=["paused", "leave"], default="paused")
225
+ sp.add_argument("--batch", type=int, default=25, help="leads per import request (avoids timeouts on big campaigns)")
226
+ sp.add_argument("--activate", action="store_true", help="flip campaign to active after import (SENDS)")
227
+ sp.add_argument("--force", action="store_true", help="push even if the gauntlet fails")
228
+ sp.set_defaults(func=cmd_push)
229
+
230
+ sp = sub.add_parser("status", help="show staged enrollments / previews / sends")
231
+ sp.add_argument("campaign_id")
232
+ sp.set_defaults(func=cmd_status)
233
+
234
+ args = p.parse_args(argv)
235
+ try:
236
+ return args.func(args)
237
+ except FileNotFoundError as e:
238
+ print(f"✗ {e}. Run `letz pull {getattr(args,'campaign_id','<id>')}` first.")
239
+ return 2
240
+ except LetzAPIError as e:
241
+ print(f"✗ API error {e.status}: {e.body}")
242
+ return 2
243
+
244
+
245
+ if __name__ == "__main__":
246
+ raise SystemExit(main())
letz/api.py ADDED
@@ -0,0 +1,79 @@
1
+ """Thin HTTP client for the Letz platform API (stdlib only).
2
+
3
+ A pure reader/writer over HTTP: it talks to the platform's public REST API
4
+ and nothing else, so pushing drafts can never trigger a send by itself.
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+
12
+ class LetzAPIError(Exception):
13
+ def __init__(self, status, body):
14
+ self.status = status
15
+ self.body = body
16
+ super().__init__(f"HTTP {status}: {str(body)[:300]}")
17
+
18
+
19
+ class LetzAPI:
20
+ def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 120):
21
+ self.base = base_url.rstrip("/")
22
+ self.timeout = timeout
23
+
24
+ def _req(self, method: str, path: str, body=None):
25
+ url = self.base + ("/api/v1" if not path.startswith("/api/") else "") + path
26
+ data = json.dumps(body).encode() if body is not None else None
27
+ req = urllib.request.Request(
28
+ url, data=data, method=method,
29
+ headers={"Content-Type": "application/json"},
30
+ )
31
+ try:
32
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
33
+ raw = resp.read().decode() or "{}"
34
+ return json.loads(raw)
35
+ except urllib.error.HTTPError as e:
36
+ try:
37
+ payload = json.loads(e.read().decode() or "{}")
38
+ except Exception:
39
+ payload = {"detail": "unreadable error body"}
40
+ raise LetzAPIError(e.code, payload)
41
+
42
+ # ---- reads ----
43
+ def campaign(self, campaign_id: str) -> dict | None:
44
+ """No single-campaign GET exists; fetch the list and filter."""
45
+ data = self._req("GET", "/campaigns?limit=200")
46
+ for c in data.get("campaigns", []):
47
+ if c.get("id") == campaign_id:
48
+ return c
49
+ return None
50
+
51
+ def leads(self, campaign_id: str, limit: int = 1000) -> list:
52
+ data = self._req("GET", f"/campaigns/{campaign_id}/leads?limit={limit}")
53
+ return data.get("leads", [])
54
+
55
+ def persona(self) -> dict:
56
+ try:
57
+ return self._req("GET", "/brand-persona")
58
+ except LetzAPIError:
59
+ return {}
60
+
61
+ def previews(self, campaign_id: str) -> dict:
62
+ return self._req("GET", f"/campaigns/{campaign_id}/previews")
63
+
64
+ def enrollments(self, campaign_id: str) -> list:
65
+ return self._req("GET", f"/sequences/enrollments?campaign_id={campaign_id}").get("enrollments", [])
66
+
67
+ def sends(self, campaign_id: str, limit: int = 200) -> list:
68
+ return self._req("GET", f"/sequences/sends?campaign_id={campaign_id}&limit={limit}").get("sends", [])
69
+
70
+ # ---- writes ----
71
+ def import_sequences(self, campaign_id: str, items: list, stage: str = "paused") -> dict:
72
+ return self._req("POST", "/sequences/import",
73
+ {"campaign_id": campaign_id, "items": items, "stage": stage})
74
+
75
+ def activate(self, campaign_id: str) -> dict:
76
+ return self._req("POST", f"/campaigns/{campaign_id}/activate")
77
+
78
+ def pause(self, campaign_id: str) -> dict:
79
+ return self._req("POST", f"/campaigns/{campaign_id}/pause")
letz/gauntlet.py ADDED
@@ -0,0 +1,124 @@
1
+ """Pure-Python quality gauntlet for the Letz Brain CLI.
2
+
3
+ Parity port of the platform's DETERMINISTIC email checks so off-platform
4
+ generation meets the same bar the in-app critic enforced — with ZERO AI.
5
+
6
+ Sources mirrored:
7
+ - services/critic.py:_deterministic_penalties + BAD_PHRASES
8
+ - services/email_audit.py:audit_email_deterministic + BANNED_PHRASES
9
+ Plus the operator's hard rule: NO em dashes.
10
+
11
+ The LLM-rubric half of the critic is replaced by careful generation upstream;
12
+ this module is the hard gate that runs locally before anything is pushed.
13
+ """
14
+ from __future__ import annotations
15
+ import re
16
+
17
+ EM_DASH = "—" # —
18
+ EN_DASH = "–" # –
19
+
20
+ # Union of services/critic.py BAD_PHRASES and services/email_audit.py BANNED_PHRASES.
21
+ BANNED_PHRASES = sorted(set([
22
+ "i hope this email finds you well", "i hope this finds you well",
23
+ "i hope you're well", "hope you're doing well", "i wanted to reach out",
24
+ "just reaching out", "i came across your", "circle back", "circling back",
25
+ "touch base", "synergy", "leverage our", "leverage", "deep dive",
26
+ "going forward", "low-hanging fruit", "move the needle",
27
+ "at your earliest convenience", "let me know your thoughts", "quick question",
28
+ "following up", "just following up", "checking in", "as per my last email",
29
+ "as discussed", "boil the ocean", "drink from the fire hose",
30
+ ]))
31
+
32
+ # Brevity: ideal ceiling and a hard ceiling (mirrors email_audit 90 * 1.45 ~ 130;
33
+ # critic used 150/180). We keep a generous hard ceiling so short, human notes pass.
34
+ IDEAL_MAX_WORDS = 120
35
+ HARD_MAX_WORDS = 180
36
+ MIN_WORDS = 25
37
+
38
+
39
+ def audit_email(subject: str, body: str, forbidden_names=None) -> dict:
40
+ """Audit one email. Returns {ok, hard, soft, word_count}.
41
+
42
+ `hard` issues MUST be fixed before pushing (the CLI `check` fails on any).
43
+ `soft` issues are advisory.
44
+ """
45
+ subject = (subject or "").strip()
46
+ body = (body or "").strip()
47
+
48
+ if isinstance(forbidden_names, str):
49
+ forbidden = [n.strip() for n in forbidden_names.split(",") if n.strip()]
50
+ elif isinstance(forbidden_names, (list, tuple)):
51
+ forbidden = [str(n).strip() for n in forbidden_names if str(n).strip()]
52
+ else:
53
+ forbidden = []
54
+
55
+ hard: list[str] = []
56
+ soft: list[str] = []
57
+
58
+ # --- empties ---
59
+ if not subject:
60
+ hard.append("Subject is empty.")
61
+ elif len(subject) < 6:
62
+ hard.append(f"Subject is too short ({len(subject)} chars) — make it specific.")
63
+ elif len(subject) > 70:
64
+ soft.append("Subject is over 70 chars — cap ~60 so it renders on mobile.")
65
+ if not body:
66
+ hard.append("Body is empty.")
67
+
68
+ combined = f"{subject}\n{body}"
69
+ combined_lc = combined.lower()
70
+
71
+ # --- NO em dashes (operator rule) ---
72
+ if EM_DASH in combined or EN_DASH in combined:
73
+ hard.append("Contains an em/en dash — replace with a comma, period, or rewrite.")
74
+
75
+ # --- banned / templated phrases ---
76
+ hits = [p for p in BANNED_PHRASES if p in combined_lc]
77
+ if hits:
78
+ hard.append(f"Templated/spam phrase(s): {', '.join(hits[:4])}.")
79
+
80
+ # --- forbidden names (operator real name etc.) ---
81
+ leaks = [n for n in forbidden if re.search(rf"\b{re.escape(n)}\b", combined, re.IGNORECASE)]
82
+ if leaks:
83
+ hard.append(f"Forbidden name(s) leaked: {', '.join(leaks)}.")
84
+
85
+ # --- brevity ---
86
+ word_count = len(body.split())
87
+ if word_count > HARD_MAX_WORDS:
88
+ hard.append(f"Body is {word_count} words — over the {HARD_MAX_WORDS} hard ceiling. Cut it.")
89
+ elif word_count > IDEAL_MAX_WORDS:
90
+ soft.append(f"Body is {word_count} words — over the {IDEAL_MAX_WORDS} ideal. Trim.")
91
+ if 0 < word_count < MIN_WORDS:
92
+ soft.append(f"Body is only {word_count} words — add one specific, concrete detail.")
93
+
94
+ # --- link density ---
95
+ links = len(re.findall(r"https?://", body))
96
+ if links > 2:
97
+ hard.append(f"{links} links — one max, ideally just the CTA.")
98
+ elif links == 2:
99
+ soft.append("Two links — prefer one.")
100
+
101
+ # --- one clear ask ---
102
+ q = body.count("?")
103
+ if q == 0:
104
+ soft.append("No question/ask — end with one soft yes/no question.")
105
+ elif q >= 3:
106
+ soft.append(f"{q} questions — pick the single best ask.")
107
+
108
+ return {"ok": len(hard) == 0, "hard": hard, "soft": soft, "word_count": word_count}
109
+
110
+
111
+ def audit_sequence(emails: list, forbidden_names=None, expected_steps: int | None = None) -> dict:
112
+ """Audit a full sequence. Returns {ok, per_email:[...], issues:[...]}."""
113
+ issues: list[str] = []
114
+ per_email = []
115
+ if not emails:
116
+ return {"ok": False, "per_email": [], "issues": ["No emails in sequence."]}
117
+ if expected_steps and len(emails) < expected_steps:
118
+ issues.append(f"Only {len(emails)} of {expected_steps} expected touches.")
119
+ for i, e in enumerate(emails):
120
+ r = audit_email(e.get("subject", ""), e.get("body", ""), forbidden_names)
121
+ r["step"] = e.get("step", i + 1)
122
+ per_email.append(r)
123
+ ok = all(r["ok"] for r in per_email) and not issues
124
+ return {"ok": ok, "per_email": per_email, "issues": issues}
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: letz-cli
3
+ Version: 0.1.0
4
+ Summary: Letz Brain CLI - pull campaigns from the Letz platform, draft email sequences locally with Claude, and push them back staged for review.
5
+ Project-URL: Homepage, https://sam.weareletz.com
6
+ Author: Letz
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: claude,email,letz,outreach,sdr,sequences
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Other Audience
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Communications :: Email
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Letz Brain CLI
23
+
24
+ Generate email sequences **off-platform** (on flat-rate Claude compute), push them to
25
+ the [Letz](https://sam.weareletz.com) platform staged for review, and let the cloud
26
+ **just click send**. Zero cloud AI cost.
27
+
28
+ ## Why
29
+
30
+ Every time the cloud app composes a sequence it spends metered AI money. This CLI moves
31
+ all of that generation onto your own Claude subscription. The platform only renders the
32
+ finished drafts for review and sends them when a human clicks **Activate**.
33
+
34
+ ## Install
35
+
36
+ Requires Python 3.10+. No other dependencies (pure standard library).
37
+
38
+ ```bash
39
+ pip install letz-cli
40
+ # or, in a managed Python environment:
41
+ pipx install letz-cli
42
+ ```
43
+
44
+ Then point it at your Letz platform:
45
+
46
+ ```bash
47
+ export LETZ_API_URL="https://your-letz-instance.example.com"
48
+ ```
49
+
50
+ ## Workflow
51
+
52
+ ```bash
53
+ # 1. Pull a campaign's config + leads. Writes letz_work/<id>.pull.json and a
54
+ # letz_work/<id>.ready.json scaffold (one entry per lead, empty emails).
55
+ letz pull <campaign_id>
56
+
57
+ # 2. GENERATION happens HERE: fill the "emails" arrays in letz_work/<id>.ready.json.
58
+ # In an interactive Claude session, Claude writes the sequences directly into that
59
+ # file, following the campaign's style guide + brand persona. Run this step on a
60
+ # subscription-auth Claude session — not a metered API key — or the cost saving
61
+ # is lost.
62
+
63
+ # 3. Quality gate (pure Python, no AI). Fails on em dashes, spam phrases, forbidden
64
+ # names, over-length, etc.
65
+ letz check <campaign_id>
66
+
67
+ # 4. Push staged (PAUSED — nothing sends). Idempotent.
68
+ letz push <campaign_id>
69
+
70
+ # 5. Review the drafts in the platform UI (campaign detail → previews), then click
71
+ # Activate. Or release from the CLI:
72
+ letz push <campaign_id> --activate # flips campaign active → sending begins in-window
73
+
74
+ # Inspect anytime:
75
+ letz status <campaign_id>
76
+ ```
77
+
78
+ ## Commands
79
+
80
+ | Command | What it does | AI? |
81
+ |---|---|---|
82
+ | `pull <id>` | fetch campaign + persona + leads → pull.json + ready.json scaffold | no |
83
+ | `check <id>` | run the deterministic quality gauntlet on ready.json | no |
84
+ | `push <id>` | import drafts, staged paused; re-runs the gauntlet first | no |
85
+ | `push <id> --activate` | push **and** flip the campaign active (begins sending) | no |
86
+ | `status <id>` | staged enrollments / previews / sends | no |
87
+
88
+ Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default `./letz_work`).
89
+
90
+ ## Safety
91
+
92
+ - The CLI talks only to the platform's REST API. Pushing drafts can never trigger a
93
+ send by itself.
94
+ - `push` stages **paused** by default and re-runs the gauntlet; it refuses hard-failing
95
+ content unless `--force`.
96
+ - Only `--activate` (or a human clicking Activate) ever causes a send.
@@ -0,0 +1,9 @@
1
+ letz/__init__.py,sha256=UDQqxTNCKKhML7K3pPlzcYqtVBeMLV1Uq5TRS95LR48,214
2
+ letz/__main__.py,sha256=IpSSZcQ6cGaqwMRZm7xQzsEhEUgDhs_WuAo8rs6hq74,10296
3
+ letz/api.py,sha256=bbr2_33ntQlk0eB3jO6iBRPvJ7_iYRz0vr4w6T2VTRs,3099
4
+ letz/gauntlet.py,sha256=fNvxyFCVjTqOkoq3F6qk4iwH5Km46jyGHkH4PXLOGeg,5095
5
+ letz_cli-0.1.0.dist-info/METADATA,sha256=HdGC8Kl7PMCDj839ru-nBlftYZBaCKrlUEHLwLzOWwU,3537
6
+ letz_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ letz_cli-0.1.0.dist-info/entry_points.txt,sha256=u5w6F6NTgKAV_BKk4lwXPrgNWnoKj7zId7aw-FruWio,44
8
+ letz_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Wb2vPbDpyeyMC9hZFeqDG_5Aus570XDUTBnIcyPv-go,1061
9
+ letz_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ letz = letz.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Letz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.