letz-cli 0.1.0__tar.gz → 0.2.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 → letz_cli-0.2.0}/.gitignore +1 -0
- {letz_cli-0.1.0 → letz_cli-0.2.0}/PKG-INFO +25 -23
- {letz_cli-0.1.0 → letz_cli-0.2.0}/README.md +24 -22
- {letz_cli-0.1.0 → letz_cli-0.2.0}/letz/__init__.py +1 -1
- {letz_cli-0.1.0 → letz_cli-0.2.0}/letz/__main__.py +41 -0
- {letz_cli-0.1.0 → letz_cli-0.2.0}/letz/api.py +11 -2
- {letz_cli-0.1.0 → letz_cli-0.2.0}/letz/gauntlet.py +4 -10
- {letz_cli-0.1.0 → letz_cli-0.2.0}/LICENSE +0 -0
- {letz_cli-0.1.0 → letz_cli-0.2.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
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
5
|
Project-URL: Homepage, https://sam.weareletz.com
|
|
6
6
|
Author: Letz
|
|
@@ -25,29 +25,28 @@ Generate email sequences **off-platform** (on flat-rate Claude compute), push th
|
|
|
25
25
|
the [Letz](https://sam.weareletz.com) platform staged for review, and let the cloud
|
|
26
26
|
**just click send**. Zero cloud AI cost.
|
|
27
27
|
|
|
28
|
-
##
|
|
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).
|
|
28
|
+
## 60-second start
|
|
37
29
|
|
|
38
30
|
```bash
|
|
39
|
-
pip install letz-cli
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
pip install letz-cli # or: pipx install letz-cli
|
|
32
|
+
export LETZ_API_URL="https://your-letz-instance.example.com"
|
|
33
|
+
letz campaigns # list your campaigns + ids
|
|
34
|
+
letz prompt <CAMPAIGN_ID> # print the playbook for Claude
|
|
42
35
|
```
|
|
43
36
|
|
|
44
|
-
|
|
37
|
+
Paste the output of `letz prompt` into a [Claude Code](https://docs.claude.com/en/docs/claude-code/overview)
|
|
38
|
+
session signed in with a **Claude Pro or Max subscription** (not a metered API key — the
|
|
39
|
+
subscription is what makes generation free). Claude runs the whole loop below for you,
|
|
40
|
+
writes every email, and stages the result **paused**. You review in the platform UI and
|
|
41
|
+
click **Activate** when happy. Nothing ever sends on its own.
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
Every time the cloud app composes a sequence it spends metered AI money. This CLI moves
|
|
46
|
+
all of that generation onto your own Claude subscription. The platform only renders the
|
|
47
|
+
finished drafts for review and sends them when a human clicks **Activate**.
|
|
49
48
|
|
|
50
|
-
##
|
|
49
|
+
## The loop, step by step
|
|
51
50
|
|
|
52
51
|
```bash
|
|
53
52
|
# 1. Pull a campaign's config + leads. Writes letz_work/<id>.pull.json and a
|
|
@@ -56,15 +55,13 @@ letz pull <campaign_id>
|
|
|
56
55
|
|
|
57
56
|
# 2. GENERATION happens HERE: fill the "emails" arrays in letz_work/<id>.ready.json.
|
|
58
57
|
# In an interactive Claude session, Claude writes the sequences directly into that
|
|
59
|
-
# file, following the campaign's style guide + brand persona.
|
|
60
|
-
# subscription-auth Claude session — not a metered API key — or the cost saving
|
|
61
|
-
# is lost.
|
|
58
|
+
# file, following the campaign's style guide + brand persona.
|
|
62
59
|
|
|
63
60
|
# 3. Quality gate (pure Python, no AI). Fails on em dashes, spam phrases, forbidden
|
|
64
61
|
# names, over-length, etc.
|
|
65
62
|
letz check <campaign_id>
|
|
66
63
|
|
|
67
|
-
# 4. Push staged (PAUSED — nothing sends). Idempotent.
|
|
64
|
+
# 4. Push staged (PAUSED — nothing sends). Idempotent; safe to re-run.
|
|
68
65
|
letz push <campaign_id>
|
|
69
66
|
|
|
70
67
|
# 5. Review the drafts in the platform UI (campaign detail → previews), then click
|
|
@@ -79,13 +76,18 @@ letz status <campaign_id>
|
|
|
79
76
|
|
|
80
77
|
| Command | What it does | AI? |
|
|
81
78
|
|---|---|---|
|
|
79
|
+
| `campaigns` | list your campaigns with their ids | no |
|
|
80
|
+
| `prompt [id]` | print the recommended prompt to paste into Claude Code | no |
|
|
82
81
|
| `pull <id>` | fetch campaign + persona + leads → pull.json + ready.json scaffold | no |
|
|
83
82
|
| `check <id>` | run the deterministic quality gauntlet on ready.json | no |
|
|
84
83
|
| `push <id>` | import drafts, staged paused; re-runs the gauntlet first | no |
|
|
85
84
|
| `push <id> --activate` | push **and** flip the campaign active (begins sending) | no |
|
|
86
85
|
| `status <id>` | staged enrollments / previews / sends | no |
|
|
87
86
|
|
|
88
|
-
Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default
|
|
87
|
+
Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default
|
|
88
|
+
`./letz_work`), `--version`. Global flags go before the command: `letz --api <url> status <id>`.
|
|
89
|
+
|
|
90
|
+
Requires Python 3.10+. Zero dependencies (pure standard library).
|
|
89
91
|
|
|
90
92
|
## Safety
|
|
91
93
|
|
|
@@ -4,29 +4,28 @@ Generate email sequences **off-platform** (on flat-rate Claude compute), push th
|
|
|
4
4
|
the [Letz](https://sam.weareletz.com) platform staged for review, and let the cloud
|
|
5
5
|
**just click send**. Zero cloud AI cost.
|
|
6
6
|
|
|
7
|
-
##
|
|
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).
|
|
7
|
+
## 60-second start
|
|
16
8
|
|
|
17
9
|
```bash
|
|
18
|
-
pip install letz-cli
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
pip install letz-cli # or: pipx install letz-cli
|
|
11
|
+
export LETZ_API_URL="https://your-letz-instance.example.com"
|
|
12
|
+
letz campaigns # list your campaigns + ids
|
|
13
|
+
letz prompt <CAMPAIGN_ID> # print the playbook for Claude
|
|
21
14
|
```
|
|
22
15
|
|
|
23
|
-
|
|
16
|
+
Paste the output of `letz prompt` into a [Claude Code](https://docs.claude.com/en/docs/claude-code/overview)
|
|
17
|
+
session signed in with a **Claude Pro or Max subscription** (not a metered API key — the
|
|
18
|
+
subscription is what makes generation free). Claude runs the whole loop below for you,
|
|
19
|
+
writes every email, and stages the result **paused**. You review in the platform UI and
|
|
20
|
+
click **Activate** when happy. Nothing ever sends on its own.
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
Every time the cloud app composes a sequence it spends metered AI money. This CLI moves
|
|
25
|
+
all of that generation onto your own Claude subscription. The platform only renders the
|
|
26
|
+
finished drafts for review and sends them when a human clicks **Activate**.
|
|
28
27
|
|
|
29
|
-
##
|
|
28
|
+
## The loop, step by step
|
|
30
29
|
|
|
31
30
|
```bash
|
|
32
31
|
# 1. Pull a campaign's config + leads. Writes letz_work/<id>.pull.json and a
|
|
@@ -35,15 +34,13 @@ letz pull <campaign_id>
|
|
|
35
34
|
|
|
36
35
|
# 2. GENERATION happens HERE: fill the "emails" arrays in letz_work/<id>.ready.json.
|
|
37
36
|
# In an interactive Claude session, Claude writes the sequences directly into that
|
|
38
|
-
# file, following the campaign's style guide + brand persona.
|
|
39
|
-
# subscription-auth Claude session — not a metered API key — or the cost saving
|
|
40
|
-
# is lost.
|
|
37
|
+
# file, following the campaign's style guide + brand persona.
|
|
41
38
|
|
|
42
39
|
# 3. Quality gate (pure Python, no AI). Fails on em dashes, spam phrases, forbidden
|
|
43
40
|
# names, over-length, etc.
|
|
44
41
|
letz check <campaign_id>
|
|
45
42
|
|
|
46
|
-
# 4. Push staged (PAUSED — nothing sends). Idempotent.
|
|
43
|
+
# 4. Push staged (PAUSED — nothing sends). Idempotent; safe to re-run.
|
|
47
44
|
letz push <campaign_id>
|
|
48
45
|
|
|
49
46
|
# 5. Review the drafts in the platform UI (campaign detail → previews), then click
|
|
@@ -58,13 +55,18 @@ letz status <campaign_id>
|
|
|
58
55
|
|
|
59
56
|
| Command | What it does | AI? |
|
|
60
57
|
|---|---|---|
|
|
58
|
+
| `campaigns` | list your campaigns with their ids | no |
|
|
59
|
+
| `prompt [id]` | print the recommended prompt to paste into Claude Code | no |
|
|
61
60
|
| `pull <id>` | fetch campaign + persona + leads → pull.json + ready.json scaffold | no |
|
|
62
61
|
| `check <id>` | run the deterministic quality gauntlet on ready.json | no |
|
|
63
62
|
| `push <id>` | import drafts, staged paused; re-runs the gauntlet first | no |
|
|
64
63
|
| `push <id> --activate` | push **and** flip the campaign active (begins sending) | no |
|
|
65
64
|
| `status <id>` | staged enrollments / previews / sends | no |
|
|
66
65
|
|
|
67
|
-
Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default
|
|
66
|
+
Flags: `--api <url>` (or `$LETZ_API_URL`), `--work <dir>` (or `$LETZ_WORK`, default
|
|
67
|
+
`./letz_work`), `--version`. Global flags go before the command: `letz --api <url> status <id>`.
|
|
68
|
+
|
|
69
|
+
Requires Python 3.10+. Zero dependencies (pure standard library).
|
|
68
70
|
|
|
69
71
|
## Safety
|
|
70
72
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Letz Brain CLI entrypoint.
|
|
2
2
|
|
|
3
3
|
Workflow:
|
|
4
|
+
letz campaigns # list your campaigns + ids
|
|
4
5
|
letz pull <campaign_id> # fetch campaign + persona + leads -> work/<id>.pull.json
|
|
5
6
|
# (also writes a work/<id>.ready.json scaffold to fill)
|
|
6
7
|
# --- generation happens HERE (Claude fills work/<id>.ready.json) ---
|
|
@@ -21,14 +22,27 @@ import os
|
|
|
21
22
|
import sys
|
|
22
23
|
|
|
23
24
|
try:
|
|
25
|
+
from . import __version__
|
|
24
26
|
from .api import LetzAPI, LetzAPIError
|
|
25
27
|
from .gauntlet import audit_sequence
|
|
26
28
|
except ImportError: # allow running as a plain script: python cli/letz/__main__.py
|
|
27
29
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
30
|
+
from letz import __version__
|
|
28
31
|
from letz.api import LetzAPI, LetzAPIError
|
|
29
32
|
from letz.gauntlet import audit_sequence
|
|
30
33
|
|
|
31
34
|
|
|
35
|
+
CLAUDE_PROMPT = """You are operating the Letz Brain CLI (the `letz` command, installed via `pip install letz-cli`). Do this for campaign {cid}:
|
|
36
|
+
|
|
37
|
+
1. Run: letz pull {cid}
|
|
38
|
+
2. Open letz_work/{cid}.ready.json. For every lead, write the "emails" array following the campaign's own style_guide and strategy_brief shown in the pull file. Keep it plain and human, no em dashes, personalize with first name and company only, never invent facts, and use the brand persona sender name (never the operator's real name).
|
|
39
|
+
3. Run: letz check {cid} and fix every hard issue until it reports 0.
|
|
40
|
+
4. Run: letz push {cid} (this stages everything PAUSED, it never sends).
|
|
41
|
+
5. Tell me to review the drafts in the platform UI and click Activate.
|
|
42
|
+
|
|
43
|
+
Do all generation yourself in this session on my Claude subscription. Never call a metered API key. Never activate a campaign without my explicit go-ahead."""
|
|
44
|
+
|
|
45
|
+
|
|
32
46
|
def _work_dir(args) -> str:
|
|
33
47
|
d = args.work or os.environ.get("LETZ_WORK") or os.path.join(os.getcwd(), "letz_work")
|
|
34
48
|
os.makedirs(d, exist_ok=True)
|
|
@@ -50,6 +64,25 @@ def _save(path, obj):
|
|
|
50
64
|
|
|
51
65
|
|
|
52
66
|
# ---------------------------------------------------------------- commands ----
|
|
67
|
+
def cmd_campaigns(args):
|
|
68
|
+
api = LetzAPI(args.api)
|
|
69
|
+
camps = api.campaigns()
|
|
70
|
+
if not camps:
|
|
71
|
+
print(f"No campaigns found at {args.api}")
|
|
72
|
+
return 0
|
|
73
|
+
width = max(len(c.get("name", "") or "") for c in camps)
|
|
74
|
+
print(f"{'ID':36} {'STATUS':9} NAME")
|
|
75
|
+
for c in sorted(camps, key=lambda c: (c.get("status") != "active", c.get("name") or "")):
|
|
76
|
+
print(f"{c.get('id',''):36} {c.get('status','?'):9} {(c.get('name') or ''):{width}}")
|
|
77
|
+
print(f"\n{len(camps)} campaign(s). Next: letz pull <ID> (or: letz prompt <ID> for the full Claude playbook)")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_prompt(args):
|
|
82
|
+
print(CLAUDE_PROMPT.format(cid=args.campaign_id or "<CAMPAIGN_ID>"))
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
53
86
|
def cmd_pull(args):
|
|
54
87
|
api = LetzAPI(args.api)
|
|
55
88
|
camp = api.campaign(args.campaign_id)
|
|
@@ -205,10 +238,18 @@ def cmd_status(args):
|
|
|
205
238
|
|
|
206
239
|
def main(argv=None):
|
|
207
240
|
p = argparse.ArgumentParser(prog="letz", description="Letz Brain CLI — off-platform generation, persist, click send.")
|
|
241
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
208
242
|
p.add_argument("--api", default=os.environ.get("LETZ_API_URL", "http://localhost:8000"), help="platform base URL")
|
|
209
243
|
p.add_argument("--work", default=None, help="work dir (default ./letz_work)")
|
|
210
244
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
211
245
|
|
|
246
|
+
sp = sub.add_parser("campaigns", help="list your campaigns with their ids")
|
|
247
|
+
sp.set_defaults(func=cmd_campaigns)
|
|
248
|
+
|
|
249
|
+
sp = sub.add_parser("prompt", help="print the recommended prompt to paste into Claude Code")
|
|
250
|
+
sp.add_argument("campaign_id", nargs="?", default=None)
|
|
251
|
+
sp.set_defaults(func=cmd_prompt)
|
|
252
|
+
|
|
212
253
|
sp = sub.add_parser("pull", help="fetch campaign + leads, write a ready.json scaffold")
|
|
213
254
|
sp.add_argument("campaign_id")
|
|
214
255
|
sp.add_argument("--limit", type=int, default=1000)
|
|
@@ -38,12 +38,21 @@ class LetzAPI:
|
|
|
38
38
|
except Exception:
|
|
39
39
|
payload = {"detail": "unreadable error body"}
|
|
40
40
|
raise LetzAPIError(e.code, payload)
|
|
41
|
+
except urllib.error.URLError as e:
|
|
42
|
+
raise LetzAPIError(0, {
|
|
43
|
+
"detail": f"Could not reach {self.base} ({e.reason}). "
|
|
44
|
+
"Set LETZ_API_URL to your Letz platform URL, e.g. "
|
|
45
|
+
'export LETZ_API_URL="https://your-letz-instance.example.com"'
|
|
46
|
+
})
|
|
41
47
|
|
|
42
48
|
# ---- reads ----
|
|
49
|
+
def campaigns(self, limit: int = 200) -> list:
|
|
50
|
+
data = self._req("GET", f"/campaigns?limit={limit}")
|
|
51
|
+
return data.get("campaigns", [])
|
|
52
|
+
|
|
43
53
|
def campaign(self, campaign_id: str) -> dict | None:
|
|
44
54
|
"""No single-campaign GET exists; fetch the list and filter."""
|
|
45
|
-
|
|
46
|
-
for c in data.get("campaigns", []):
|
|
55
|
+
for c in self.campaigns():
|
|
47
56
|
if c.get("id") == campaign_id:
|
|
48
57
|
return c
|
|
49
58
|
return None
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
"""Pure-Python quality gauntlet for the Letz Brain CLI.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
generation meets the same bar the in-app critic
|
|
3
|
+
Mirrors the Letz platform's deterministic email checks so off-platform
|
|
4
|
+
generation meets the same bar the in-app critic enforces — with ZERO AI.
|
|
5
|
+
Hard rule included: NO em dashes.
|
|
5
6
|
|
|
6
|
-
|
|
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.
|
|
7
|
+
This module is the hard gate that runs locally before anything is pushed.
|
|
13
8
|
"""
|
|
14
9
|
from __future__ import annotations
|
|
15
10
|
import re
|
|
@@ -17,7 +12,6 @@ import re
|
|
|
17
12
|
EM_DASH = "—" # —
|
|
18
13
|
EN_DASH = "–" # –
|
|
19
14
|
|
|
20
|
-
# Union of services/critic.py BAD_PHRASES and services/email_audit.py BANNED_PHRASES.
|
|
21
15
|
BANNED_PHRASES = sorted(set([
|
|
22
16
|
"i hope this email finds you well", "i hope this finds you well",
|
|
23
17
|
"i hope you're well", "hope you're doing well", "i wanted to reach out",
|
|
File without changes
|
|
File without changes
|