letz-cli 0.1.0__tar.gz
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_cli-0.1.0/.gitignore +4 -0
- letz_cli-0.1.0/LICENSE +21 -0
- letz_cli-0.1.0/PKG-INFO +96 -0
- letz_cli-0.1.0/README.md +75 -0
- letz_cli-0.1.0/letz/__init__.py +5 -0
- letz_cli-0.1.0/letz/__main__.py +246 -0
- letz_cli-0.1.0/letz/api.py +79 -0
- letz_cli-0.1.0/letz/gauntlet.py +124 -0
- letz_cli-0.1.0/pyproject.toml +41 -0
letz_cli-0.1.0/LICENSE
ADDED
|
@@ -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.
|
letz_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
letz_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Letz Brain CLI
|
|
2
|
+
|
|
3
|
+
Generate email sequences **off-platform** (on flat-rate Claude compute), push them to
|
|
4
|
+
the [Letz](https://sam.weareletz.com) platform staged for review, and let the cloud
|
|
5
|
+
**just click send**. Zero cloud AI cost.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Every time the cloud app composes a sequence it spends metered AI money. This CLI moves
|
|
10
|
+
all of that generation onto your own Claude subscription. The platform only renders the
|
|
11
|
+
finished drafts for review and sends them when a human clicks **Activate**.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Requires Python 3.10+. No other dependencies (pure standard library).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install letz-cli
|
|
19
|
+
# or, in a managed Python environment:
|
|
20
|
+
pipx install letz-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then point it at your Letz platform:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export LETZ_API_URL="https://your-letz-instance.example.com"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Workflow
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Pull a campaign's config + leads. Writes letz_work/<id>.pull.json and a
|
|
33
|
+
# letz_work/<id>.ready.json scaffold (one entry per lead, empty emails).
|
|
34
|
+
letz pull <campaign_id>
|
|
35
|
+
|
|
36
|
+
# 2. GENERATION happens HERE: fill the "emails" arrays in letz_work/<id>.ready.json.
|
|
37
|
+
# In an interactive Claude session, Claude writes the sequences directly into that
|
|
38
|
+
# file, following the campaign's style guide + brand persona. Run this step on a
|
|
39
|
+
# subscription-auth Claude session — not a metered API key — or the cost saving
|
|
40
|
+
# is lost.
|
|
41
|
+
|
|
42
|
+
# 3. Quality gate (pure Python, no AI). Fails on em dashes, spam phrases, forbidden
|
|
43
|
+
# names, over-length, etc.
|
|
44
|
+
letz check <campaign_id>
|
|
45
|
+
|
|
46
|
+
# 4. Push staged (PAUSED — nothing sends). Idempotent.
|
|
47
|
+
letz push <campaign_id>
|
|
48
|
+
|
|
49
|
+
# 5. Review the drafts in the platform UI (campaign detail → previews), then click
|
|
50
|
+
# Activate. Or release from the CLI:
|
|
51
|
+
letz push <campaign_id> --activate # flips campaign active → sending begins in-window
|
|
52
|
+
|
|
53
|
+
# Inspect anytime:
|
|
54
|
+
letz status <campaign_id>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | What it does | AI? |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `pull <id>` | fetch campaign + persona + leads → pull.json + ready.json scaffold | no |
|
|
62
|
+
| `check <id>` | run the deterministic quality gauntlet on ready.json | no |
|
|
63
|
+
| `push <id>` | import drafts, staged paused; re-runs the gauntlet first | no |
|
|
64
|
+
| `push <id> --activate` | push **and** flip the campaign active (begins sending) | no |
|
|
65
|
+
| `status <id>` | staged enrollments / previews / sends | no |
|
|
66
|
+
|
|
67
|
+
Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default `./letz_work`).
|
|
68
|
+
|
|
69
|
+
## Safety
|
|
70
|
+
|
|
71
|
+
- The CLI talks only to the platform's REST API. Pushing drafts can never trigger a
|
|
72
|
+
send by itself.
|
|
73
|
+
- `push` stages **paused** by default and re-runs the gauntlet; it refuses hard-failing
|
|
74
|
+
content unless `--force`.
|
|
75
|
+
- Only `--activate` (or a human clicking Activate) ever causes a send.
|
|
@@ -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())
|
|
@@ -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")
|
|
@@ -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,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "letz-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Letz Brain CLI - pull campaigns from the Letz platform, draft email sequences locally with Claude, and push them back staged for review."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Letz" }]
|
|
13
|
+
keywords = ["letz", "sdr", "outreach", "email", "sequences", "claude"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Other Audience",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Communications :: Email",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
letz = "letz.__main__:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://sam.weareletz.com"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "letz/__init__.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
ignore-vcs = true
|
|
37
|
+
only-include = ["letz", "README.md", "LICENSE", "pyproject.toml"]
|
|
38
|
+
exclude = ["**/__pycache__"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["letz"]
|