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.
@@ -2,3 +2,4 @@ dist/
2
2
  __pycache__/
3
3
  *.egg-info/
4
4
  letz_work/
5
+ .release-venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letz-cli
3
- Version: 0.1.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
- ## 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).
28
+ ## 60-second start
37
29
 
38
30
  ```bash
39
- pip install letz-cli
40
- # or, in a managed Python environment:
41
- pipx install letz-cli
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
- Then point it at your Letz platform:
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
- ```bash
47
- export LETZ_API_URL="https://your-letz-instance.example.com"
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
- ## Workflow
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. Run this step on a
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 `./letz_work`).
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
- ## 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).
7
+ ## 60-second start
16
8
 
17
9
  ```bash
18
- pip install letz-cli
19
- # or, in a managed Python environment:
20
- pipx install letz-cli
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
- Then point it at your Letz platform:
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
- ```bash
26
- export LETZ_API_URL="https://your-letz-instance.example.com"
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
- ## Workflow
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. Run this step on a
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 `./letz_work`).
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
 
@@ -2,4 +2,4 @@
2
2
  sequences locally with Claude, and push them back staged for review.
3
3
  Nothing sends until a human clicks Activate.
4
4
  """
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
@@ -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
- data = self._req("GET", "/campaigns?limit=200")
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
- 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.
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
- 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.
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