klaviyo-cli 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.
- klaviyo_cli-0.2.0/.github/workflows/ci.yml +15 -0
- klaviyo_cli-0.2.0/.github/workflows/publish.yml +13 -0
- klaviyo_cli-0.2.0/.gitignore +6 -0
- klaviyo_cli-0.2.0/LICENSE +21 -0
- klaviyo_cli-0.2.0/PKG-INFO +169 -0
- klaviyo_cli-0.2.0/README.md +147 -0
- klaviyo_cli-0.2.0/pyproject.toml +37 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/__init__.py +3 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/_util.py +189 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/cli.py +48 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/__init__.py +9 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/campaigns.py +565 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/events.py +127 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/flows.py +499 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/metrics.py +258 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/profiles.py +261 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/raw.py +34 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/segments.py +276 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/commands/sms.py +110 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/config.py +49 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/embed.py +108 -0
- klaviyo_cli-0.2.0/src/klaviyo_cli/transport.py +96 -0
- klaviyo_cli-0.2.0/tests/__init__.py +0 -0
- klaviyo_cli-0.2.0/tests/test_campaigns.py +115 -0
- klaviyo_cli-0.2.0/tests/test_cli.py +56 -0
- klaviyo_cli-0.2.0/tests/test_config.py +44 -0
- klaviyo_cli-0.2.0/tests/test_embed.py +110 -0
- klaviyo_cli-0.2.0/tests/test_events.py +133 -0
- klaviyo_cli-0.2.0/tests/test_flows.py +285 -0
- klaviyo_cli-0.2.0/tests/test_metrics.py +44 -0
- klaviyo_cli-0.2.0/tests/test_profiles.py +267 -0
- klaviyo_cli-0.2.0/tests/test_raw.py +29 -0
- klaviyo_cli-0.2.0/tests/test_segments.py +136 -0
- klaviyo_cli-0.2.0/tests/test_smoke.py +5 -0
- klaviyo_cli-0.2.0/tests/test_sms.py +77 -0
- klaviyo_cli-0.2.0/tests/test_transport.py +43 -0
- klaviyo_cli-0.2.0/uv.lock +214 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push: {branches: [main]}
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
matrix: {python-version: ["3.11", "3.12", "3.13"]}
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v5
|
|
13
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
14
|
+
- run: uv venv --python ${{ matrix.python-version }} && uv pip install -e ".[dev]"
|
|
15
|
+
- run: uv run pytest tests/ -v
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
release: {types: [published]}
|
|
4
|
+
jobs:
|
|
5
|
+
publish:
|
|
6
|
+
runs-on: ubuntu-latest
|
|
7
|
+
environment: pypi
|
|
8
|
+
permissions: {id-token: write}
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: astral-sh/setup-uv@v5
|
|
12
|
+
- run: uv build
|
|
13
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Beauchamp
|
|
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.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klaviyo-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Unofficial Klaviyo CLI: campaigns, segments, flows, metrics, scheduling. Built for humans and AI agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/BeauchampAndrew/klaviyo-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/BeauchampAndrew/klaviyo-cli/issues
|
|
7
|
+
Author-email: Andrew Beauchamp <andrew@bsandco.us>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,cli,ecommerce,email-marketing,klaviyo
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Communications :: Email
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: click>=8.0
|
|
18
|
+
Requires-Dist: requests>=2.28
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# klaviyo-cli
|
|
24
|
+
|
|
25
|
+
A command-line interface for Klaviyo: campaigns, segments, flows, metrics, and scheduling. Built for humans and AI agents.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uvx --from klaviyo-cli klaviyo --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No install needed. Or install it: `pipx install klaviyo-cli` or `uv tool install klaviyo-cli`. Requires Python 3.11+.
|
|
32
|
+
|
|
33
|
+
Built and maintained by [BS&Co](https://bsandco.us), a retention marketing agency for eCommerce brands.
|
|
34
|
+
|
|
35
|
+
> Unofficial. Not affiliated with, endorsed, or supported by Klaviyo, Inc.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export KLAVIYO_API_KEY=pk_...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Get a private API key from Klaviyo under Settings > API Keys. Read commands need read scopes; commands that change data (patch, schedule, create, upload) need write scopes.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
klaviyo list-campaigns --days 30
|
|
47
|
+
klaviyo account-health
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Using with Claude Code and AI agents
|
|
51
|
+
|
|
52
|
+
`--json` is a global flag: put it before the command name for machine-readable output on any command:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
klaviyo --json list-campaigns --days 30
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Destructive Klaviyo operations are blocked at the CLI level. Klaviyo's DELETE endpoints for profiles, lists, segments, campaigns, and flows are not reachable through this tool, even via the raw `api` passthrough. Only template deletion is allowlisted (`transport.py`, `DELETE_ALLOWED_PATHS`). This is why it's safe to hand `klaviyo-cli` to an agent with a live API key: the agent can read anything and change campaign content, timing, and audiences, but it cannot delete subscriber data, segments, or send history. One command can stop mail going to real people: `suppress`. It deletes nothing, but because it halts sends it requires an explicit `--yes` flag or an interactive confirmation before it runs.
|
|
59
|
+
|
|
60
|
+
Drop this in your repo's `CLAUDE.md` so an agent knows the tool exists:
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
## Klaviyo
|
|
64
|
+
Use the `klaviyo` CLI for Klaviyo data and actions (campaigns, segments,
|
|
65
|
+
flows, metrics). Auth via KLAVIYO_API_KEY env var. Pass --json before the
|
|
66
|
+
command for machine-readable output (e.g. `klaviyo --json list-campaigns`).
|
|
67
|
+
Run `klaviyo --help` for the full command list.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Command reference
|
|
71
|
+
|
|
72
|
+
Run `klaviyo COMMAND --help` for full options on any command.
|
|
73
|
+
|
|
74
|
+
### Campaigns
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `list-campaigns` | List/filter campaigns by status, channel, and date (beyond name search) |
|
|
78
|
+
| `search-campaigns` | Search campaigns by name |
|
|
79
|
+
| `get-campaign` | Show details for a specific campaign |
|
|
80
|
+
| `get-creative` | Dump a campaign's creative (subject + text/HTML) via its template |
|
|
81
|
+
| `list-drafts` | List draft email campaigns for a client |
|
|
82
|
+
| `patch-campaign` | Update campaign send time and/or audiences |
|
|
83
|
+
| `schedule` | Schedule a campaign for sending |
|
|
84
|
+
| `campaign-performance` | Show campaign revenue and engagement metrics |
|
|
85
|
+
| `metrics` | Show sent campaigns for a client within a date window |
|
|
86
|
+
|
|
87
|
+
### Segments
|
|
88
|
+
| Command | Description |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `list-audiences` | List all lists and segments for a client |
|
|
91
|
+
| `search-segments` | Find segments by name keyword; shows a one-line definition summary |
|
|
92
|
+
| `get-segment` | Show a segment's definition (conditions, metric IDs resolved) + count |
|
|
93
|
+
| `segment-count` | Get profile count for a single segment (rate limited: 1/s, 15/min) |
|
|
94
|
+
| `segment-sizes` | Show all segments with profile counts |
|
|
95
|
+
| `create-segment` | Create a segment from a definition, guarding against duplicates |
|
|
96
|
+
|
|
97
|
+
### Flows
|
|
98
|
+
| Command | Description |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `flows` | List all flows for a client, with optional sort and name search |
|
|
101
|
+
| `get-flow` | Show a flow's basics in one call; `--definition` adds trigger, action chain, reentry |
|
|
102
|
+
| `flow-detail` | Show full flow structure: trigger, filters, emails with subjects, delays, splits |
|
|
103
|
+
| `flow-performance` | Show flow revenue and engagement metrics |
|
|
104
|
+
| `create-flow` | Create a flow (in draft) from a definition, guarding against duplicates |
|
|
105
|
+
|
|
106
|
+
### Profiles
|
|
107
|
+
| Command | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `get-profile` | Look up a profile by email or ID; `--subscriptions` adds consent state and suppressions |
|
|
110
|
+
| `segment-members` | List profiles in a segment: email, name, and when they joined |
|
|
111
|
+
| `suppress` | Suppress profiles from email marketing in bulk (requires `--yes` or confirmation) |
|
|
112
|
+
| `unsuppress` | Remove manual suppressions in bulk (never resubscribes anyone) |
|
|
113
|
+
| `suppression-jobs` | List bulk suppression jobs (suppress + unsuppress) with status and counts |
|
|
114
|
+
|
|
115
|
+
### Events
|
|
116
|
+
| Command | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `push-event` | Push a custom event to a profile by email (creates the profile if needed) |
|
|
119
|
+
| `events` | List recent events for a metric ID, newest first, with the profile attached |
|
|
120
|
+
|
|
121
|
+
### Metrics
|
|
122
|
+
| Command | Description |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `account-health` | Show profiles count, lists, and metrics for a client |
|
|
125
|
+
| `list-metrics` | List event metrics with their IDs and integration (ID<->name catalog) |
|
|
126
|
+
| `form-performance` | Show pop-up/form views, submits, and submit rates |
|
|
127
|
+
|
|
128
|
+
### SMS
|
|
129
|
+
| Command | Description |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `upload-sms` | Create an SMS campaign draft in Klaviyo |
|
|
132
|
+
|
|
133
|
+
### Raw API
|
|
134
|
+
| Command | Description |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `api` | Raw API pass-through: `klaviyo api <METHOD> <path>` |
|
|
137
|
+
|
|
138
|
+
That's 32 commands total.
|
|
139
|
+
|
|
140
|
+
## Multi-account profiles
|
|
141
|
+
|
|
142
|
+
For managing more than one Klaviyo account, put credentials in `~/.config/klaviyo-cli/config.toml`:
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
default_profile = "acme"
|
|
146
|
+
|
|
147
|
+
[profiles.acme]
|
|
148
|
+
api_key = "pk_acme_..."
|
|
149
|
+
|
|
150
|
+
[profiles.other-brand]
|
|
151
|
+
api_key = "pk_other_..."
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Select a profile with `--profile` (or `-p`), or set `KLAVIYO_PROFILE`:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
klaviyo --profile other-brand account-health
|
|
158
|
+
KLAVIYO_PROFILE=other-brand klaviyo account-health
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Precedence: an explicit `--profile` (or `KLAVIYO_PROFILE`) wins first. Otherwise `KLAVIYO_API_KEY` is used if set. Otherwise the CLI falls back to `default_profile` in the config file.
|
|
162
|
+
|
|
163
|
+
Agencies running the CLI across many clients can go further and embed it: re-expose every command under a host CLI with per-client auth resolution, so `yourcli campaign-performance acme --days 30` resolves credentials for `acme` before the call. See the `wrap_with_account` and `build_host_group` docstrings in `src/klaviyo_cli/embed.py`.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT. See [LICENSE](LICENSE).
|
|
168
|
+
|
|
169
|
+
Built and maintained by [BS&Co](https://bsandco.us), a retention marketing agency for eCommerce brands.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# klaviyo-cli
|
|
2
|
+
|
|
3
|
+
A command-line interface for Klaviyo: campaigns, segments, flows, metrics, and scheduling. Built for humans and AI agents.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uvx --from klaviyo-cli klaviyo --help
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No install needed. Or install it: `pipx install klaviyo-cli` or `uv tool install klaviyo-cli`. Requires Python 3.11+.
|
|
10
|
+
|
|
11
|
+
Built and maintained by [BS&Co](https://bsandco.us), a retention marketing agency for eCommerce brands.
|
|
12
|
+
|
|
13
|
+
> Unofficial. Not affiliated with, endorsed, or supported by Klaviyo, Inc.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export KLAVIYO_API_KEY=pk_...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Get a private API key from Klaviyo under Settings > API Keys. Read commands need read scopes; commands that change data (patch, schedule, create, upload) need write scopes.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
klaviyo list-campaigns --days 30
|
|
25
|
+
klaviyo account-health
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Using with Claude Code and AI agents
|
|
29
|
+
|
|
30
|
+
`--json` is a global flag: put it before the command name for machine-readable output on any command:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
klaviyo --json list-campaigns --days 30
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Destructive Klaviyo operations are blocked at the CLI level. Klaviyo's DELETE endpoints for profiles, lists, segments, campaigns, and flows are not reachable through this tool, even via the raw `api` passthrough. Only template deletion is allowlisted (`transport.py`, `DELETE_ALLOWED_PATHS`). This is why it's safe to hand `klaviyo-cli` to an agent with a live API key: the agent can read anything and change campaign content, timing, and audiences, but it cannot delete subscriber data, segments, or send history. One command can stop mail going to real people: `suppress`. It deletes nothing, but because it halts sends it requires an explicit `--yes` flag or an interactive confirmation before it runs.
|
|
37
|
+
|
|
38
|
+
Drop this in your repo's `CLAUDE.md` so an agent knows the tool exists:
|
|
39
|
+
|
|
40
|
+
```markdown
|
|
41
|
+
## Klaviyo
|
|
42
|
+
Use the `klaviyo` CLI for Klaviyo data and actions (campaigns, segments,
|
|
43
|
+
flows, metrics). Auth via KLAVIYO_API_KEY env var. Pass --json before the
|
|
44
|
+
command for machine-readable output (e.g. `klaviyo --json list-campaigns`).
|
|
45
|
+
Run `klaviyo --help` for the full command list.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Command reference
|
|
49
|
+
|
|
50
|
+
Run `klaviyo COMMAND --help` for full options on any command.
|
|
51
|
+
|
|
52
|
+
### Campaigns
|
|
53
|
+
| Command | Description |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `list-campaigns` | List/filter campaigns by status, channel, and date (beyond name search) |
|
|
56
|
+
| `search-campaigns` | Search campaigns by name |
|
|
57
|
+
| `get-campaign` | Show details for a specific campaign |
|
|
58
|
+
| `get-creative` | Dump a campaign's creative (subject + text/HTML) via its template |
|
|
59
|
+
| `list-drafts` | List draft email campaigns for a client |
|
|
60
|
+
| `patch-campaign` | Update campaign send time and/or audiences |
|
|
61
|
+
| `schedule` | Schedule a campaign for sending |
|
|
62
|
+
| `campaign-performance` | Show campaign revenue and engagement metrics |
|
|
63
|
+
| `metrics` | Show sent campaigns for a client within a date window |
|
|
64
|
+
|
|
65
|
+
### Segments
|
|
66
|
+
| Command | Description |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `list-audiences` | List all lists and segments for a client |
|
|
69
|
+
| `search-segments` | Find segments by name keyword; shows a one-line definition summary |
|
|
70
|
+
| `get-segment` | Show a segment's definition (conditions, metric IDs resolved) + count |
|
|
71
|
+
| `segment-count` | Get profile count for a single segment (rate limited: 1/s, 15/min) |
|
|
72
|
+
| `segment-sizes` | Show all segments with profile counts |
|
|
73
|
+
| `create-segment` | Create a segment from a definition, guarding against duplicates |
|
|
74
|
+
|
|
75
|
+
### Flows
|
|
76
|
+
| Command | Description |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `flows` | List all flows for a client, with optional sort and name search |
|
|
79
|
+
| `get-flow` | Show a flow's basics in one call; `--definition` adds trigger, action chain, reentry |
|
|
80
|
+
| `flow-detail` | Show full flow structure: trigger, filters, emails with subjects, delays, splits |
|
|
81
|
+
| `flow-performance` | Show flow revenue and engagement metrics |
|
|
82
|
+
| `create-flow` | Create a flow (in draft) from a definition, guarding against duplicates |
|
|
83
|
+
|
|
84
|
+
### Profiles
|
|
85
|
+
| Command | Description |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `get-profile` | Look up a profile by email or ID; `--subscriptions` adds consent state and suppressions |
|
|
88
|
+
| `segment-members` | List profiles in a segment: email, name, and when they joined |
|
|
89
|
+
| `suppress` | Suppress profiles from email marketing in bulk (requires `--yes` or confirmation) |
|
|
90
|
+
| `unsuppress` | Remove manual suppressions in bulk (never resubscribes anyone) |
|
|
91
|
+
| `suppression-jobs` | List bulk suppression jobs (suppress + unsuppress) with status and counts |
|
|
92
|
+
|
|
93
|
+
### Events
|
|
94
|
+
| Command | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `push-event` | Push a custom event to a profile by email (creates the profile if needed) |
|
|
97
|
+
| `events` | List recent events for a metric ID, newest first, with the profile attached |
|
|
98
|
+
|
|
99
|
+
### Metrics
|
|
100
|
+
| Command | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `account-health` | Show profiles count, lists, and metrics for a client |
|
|
103
|
+
| `list-metrics` | List event metrics with their IDs and integration (ID<->name catalog) |
|
|
104
|
+
| `form-performance` | Show pop-up/form views, submits, and submit rates |
|
|
105
|
+
|
|
106
|
+
### SMS
|
|
107
|
+
| Command | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `upload-sms` | Create an SMS campaign draft in Klaviyo |
|
|
110
|
+
|
|
111
|
+
### Raw API
|
|
112
|
+
| Command | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `api` | Raw API pass-through: `klaviyo api <METHOD> <path>` |
|
|
115
|
+
|
|
116
|
+
That's 32 commands total.
|
|
117
|
+
|
|
118
|
+
## Multi-account profiles
|
|
119
|
+
|
|
120
|
+
For managing more than one Klaviyo account, put credentials in `~/.config/klaviyo-cli/config.toml`:
|
|
121
|
+
|
|
122
|
+
```toml
|
|
123
|
+
default_profile = "acme"
|
|
124
|
+
|
|
125
|
+
[profiles.acme]
|
|
126
|
+
api_key = "pk_acme_..."
|
|
127
|
+
|
|
128
|
+
[profiles.other-brand]
|
|
129
|
+
api_key = "pk_other_..."
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Select a profile with `--profile` (or `-p`), or set `KLAVIYO_PROFILE`:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
klaviyo --profile other-brand account-health
|
|
136
|
+
KLAVIYO_PROFILE=other-brand klaviyo account-health
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Precedence: an explicit `--profile` (or `KLAVIYO_PROFILE`) wins first. Otherwise `KLAVIYO_API_KEY` is used if set. Otherwise the CLI falls back to `default_profile` in the config file.
|
|
140
|
+
|
|
141
|
+
Agencies running the CLI across many clients can go further and embed it: re-expose every command under a host CLI with per-client auth resolution, so `yourcli campaign-performance acme --days 30` resolves credentials for `acme` before the call. See the `wrap_with_account` and `build_host_group` docstrings in `src/klaviyo_cli/embed.py`.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT. See [LICENSE](LICENSE).
|
|
146
|
+
|
|
147
|
+
Built and maintained by [BS&Co](https://bsandco.us), a retention marketing agency for eCommerce brands.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "klaviyo-cli"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Unofficial Klaviyo CLI: campaigns, segments, flows, metrics, scheduling. Built for humans and AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Andrew Beauchamp", email = "andrew@bsandco.us" }]
|
|
13
|
+
keywords = ["klaviyo", "cli", "email-marketing", "ecommerce", "ai-agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Communications :: Email",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["click>=8.0", "requests>=2.28"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/BeauchampAndrew/klaviyo-cli"
|
|
28
|
+
Issues = "https://github.com/BeauchampAndrew/klaviyo-cli/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
klaviyo = "klaviyo_cli.cli:entry"
|
|
32
|
+
|
|
33
|
+
[project.entry-points."klaviyo_cli.hosts"]
|
|
34
|
+
# (empty in this package; host packages like workspace-cli register here)
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/klaviyo_cli"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Shared helpers for command modules: timezone parsing, date ranges, output."""
|
|
2
|
+
|
|
3
|
+
import json as json_module
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
from .transport import KLAVIYO_BASE
|
|
11
|
+
|
|
12
|
+
TZ_MAP = {
|
|
13
|
+
"EST": "America/New_York",
|
|
14
|
+
"EDT": "America/New_York",
|
|
15
|
+
"CST": "America/Chicago",
|
|
16
|
+
"CDT": "America/Chicago",
|
|
17
|
+
"MST": "America/Denver",
|
|
18
|
+
"MDT": "America/Denver",
|
|
19
|
+
"PST": "America/Los_Angeles",
|
|
20
|
+
"PDT": "America/Los_Angeles",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_send_time(date_str: str, time_str: str) -> str:
|
|
25
|
+
"""Parse MM-DD-YYYY + 'HH:MM AM/PM TZ' into an ISO 8601 string with timezone."""
|
|
26
|
+
# Normalize date from MM-DD-YYYY → YYYY-MM-DD
|
|
27
|
+
parts = date_str.strip().split("-")
|
|
28
|
+
if len(parts) == 3 and len(parts[2]) == 4:
|
|
29
|
+
# MM-DD-YYYY
|
|
30
|
+
month, day, year = parts
|
|
31
|
+
iso_date = f"{year}-{month}-{day}"
|
|
32
|
+
else:
|
|
33
|
+
# Assume already YYYY-MM-DD
|
|
34
|
+
iso_date = date_str.strip()
|
|
35
|
+
|
|
36
|
+
time_parts = time_str.strip().split()
|
|
37
|
+
tz_name = "America/New_York" # default
|
|
38
|
+
|
|
39
|
+
if len(time_parts) == 3:
|
|
40
|
+
# "3:00 PM EST"
|
|
41
|
+
t, ampm, tz_abbr = time_parts
|
|
42
|
+
t_str = f"{t} {ampm}"
|
|
43
|
+
fmt = "%I:%M %p"
|
|
44
|
+
tz_name = TZ_MAP.get(tz_abbr.upper(), tz_abbr)
|
|
45
|
+
elif len(time_parts) == 2:
|
|
46
|
+
if time_parts[1].upper() in ("AM", "PM"):
|
|
47
|
+
# "3:00 PM"
|
|
48
|
+
t_str = time_str.strip()
|
|
49
|
+
fmt = "%I:%M %p"
|
|
50
|
+
else:
|
|
51
|
+
# "15:00 CST"
|
|
52
|
+
t_str = time_parts[0]
|
|
53
|
+
fmt = "%H:%M"
|
|
54
|
+
tz_name = TZ_MAP.get(time_parts[1].upper(), time_parts[1])
|
|
55
|
+
else:
|
|
56
|
+
# "15:00"
|
|
57
|
+
t_str = time_parts[0]
|
|
58
|
+
fmt = "%H:%M"
|
|
59
|
+
|
|
60
|
+
dt = datetime.strptime(f"{iso_date} {t_str}", f"%Y-%m-%d {fmt}")
|
|
61
|
+
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
|
|
62
|
+
return dt.isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_date_range(days: int, since: str | None, until: str | None):
|
|
66
|
+
"""Resolve a (start, end, label) tuple from --days / --since / --until.
|
|
67
|
+
|
|
68
|
+
If either --since or --until is provided, the explicit bounds win. Otherwise
|
|
69
|
+
falls back to (now - days) .. now. Dates are parsed as YYYY-MM-DD in UTC; the
|
|
70
|
+
end date is inclusive (23:59:59).
|
|
71
|
+
"""
|
|
72
|
+
from datetime import datetime, timedelta, timezone
|
|
73
|
+
|
|
74
|
+
end = datetime.now(timezone.utc)
|
|
75
|
+
start = end - timedelta(days=days)
|
|
76
|
+
label = f"last {days} days"
|
|
77
|
+
|
|
78
|
+
if since or until:
|
|
79
|
+
if since:
|
|
80
|
+
try:
|
|
81
|
+
start = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise click.ClickException(f"Invalid --since date {since!r} — expected YYYY-MM-DD")
|
|
84
|
+
if until:
|
|
85
|
+
try:
|
|
86
|
+
parsed = datetime.strptime(until, "%Y-%m-%d")
|
|
87
|
+
end = parsed.replace(hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise click.ClickException(f"Invalid --until date {until!r} — expected YYYY-MM-DD")
|
|
90
|
+
label = f"{start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}"
|
|
91
|
+
|
|
92
|
+
return start, end, label
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def output(data, use_json: bool = False):
|
|
96
|
+
"""Print data — raw JSON if use_json, otherwise assume caller formatted it."""
|
|
97
|
+
if use_json:
|
|
98
|
+
print(json_module.dumps(data, indent=2, default=str))
|
|
99
|
+
else:
|
|
100
|
+
if isinstance(data, str):
|
|
101
|
+
print(data)
|
|
102
|
+
else:
|
|
103
|
+
# Fallback for unformatted data — callers should format before calling
|
|
104
|
+
print(json_module.dumps(data, indent=2, default=str))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Segment definition helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
#
|
|
111
|
+
# Klaviyo segment conditions reference metrics by opaque ID (e.g. "profile-metric"
|
|
112
|
+
# conditions carry a metric_id like "XJcga2" rather than a name like "Opened
|
|
113
|
+
# Email"). Reading or building any metric-based segment requires the ID->name
|
|
114
|
+
# map, so these helpers back both list-metrics and the segment commands.
|
|
115
|
+
|
|
116
|
+
_WINDOW_UNIT = {"day": "d", "week": "w", "month": "mo", "hour": "h", "year": "y"}
|
|
117
|
+
_OP_SYMBOL = {"greater-than": ">", "greater-than-or-equal": ">=",
|
|
118
|
+
"less-than": "<", "less-than-or-equal": "<=", "equals": "="}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_metrics(call) -> dict:
|
|
122
|
+
"""Return {metric_id: {"name": str, "integration": str}} for all metrics."""
|
|
123
|
+
out: dict = {}
|
|
124
|
+
path = "/api/metrics/"
|
|
125
|
+
while path:
|
|
126
|
+
data = call("GET", path)
|
|
127
|
+
for m in data.get("data", []):
|
|
128
|
+
attrs = m.get("attributes", {})
|
|
129
|
+
out[m["id"]] = {
|
|
130
|
+
"name": attrs.get("name", "?"),
|
|
131
|
+
"integration": (attrs.get("integration") or {}).get("name", ""),
|
|
132
|
+
}
|
|
133
|
+
next_link = data.get("links", {}).get("next")
|
|
134
|
+
path = next_link.replace(KLAVIYO_BASE, "") if next_link else None
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_condition(cond: dict, metric_map: dict) -> str:
|
|
139
|
+
"""Render a single segment condition to a human-readable string."""
|
|
140
|
+
t = cond.get("type")
|
|
141
|
+
if t == "profile-marketing-consent":
|
|
142
|
+
consent = cond.get("consent") or {}
|
|
143
|
+
ch = consent.get("channel", "?")
|
|
144
|
+
if consent.get("can_receive_marketing") is False:
|
|
145
|
+
return f"can NOT receive {ch} marketing (suppressed or no consent)"
|
|
146
|
+
return f"can receive {ch} marketing"
|
|
147
|
+
if t == "profile-property":
|
|
148
|
+
prop = cond.get("property", "?")
|
|
149
|
+
m = re.match(r"properties\['(.+)'\]", prop)
|
|
150
|
+
prop = m.group(1) if m else prop
|
|
151
|
+
f = cond.get("filter") or {}
|
|
152
|
+
ft = f.get("type")
|
|
153
|
+
if ft == "existence":
|
|
154
|
+
return f"{prop} is {f.get('operator', '?')}"
|
|
155
|
+
if ft == "boolean":
|
|
156
|
+
return f"{prop} = {f.get('value')}"
|
|
157
|
+
val = f.get("value")
|
|
158
|
+
return f"{prop} {f.get('operator', '?')}{'' if val is None else ' ' + str(val)}"
|
|
159
|
+
if t == "profile-metric":
|
|
160
|
+
mid = cond.get("metric_id")
|
|
161
|
+
name = metric_map.get(mid, {}).get("name", mid)
|
|
162
|
+
mf = cond.get("measurement_filter") or {}
|
|
163
|
+
op = _OP_SYMBOL.get(mf.get("operator"), mf.get("operator", "?"))
|
|
164
|
+
val = mf.get("value")
|
|
165
|
+
tf = cond.get("timeframe_filter") or {}
|
|
166
|
+
window = ""
|
|
167
|
+
if tf.get("operator") == "in-the-last":
|
|
168
|
+
unit = _WINDOW_UNIT.get(tf.get("unit"), tf.get("unit", ""))
|
|
169
|
+
window = f" in last {tf.get('quantity')}{unit}"
|
|
170
|
+
return f"{name} {op}{val}{window}"
|
|
171
|
+
return t or "?"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_definition(definition: dict, metric_map: dict, oneline: bool = False):
|
|
175
|
+
"""Render condition_groups. Groups are AND'd; conditions within OR'd.
|
|
176
|
+
|
|
177
|
+
Returns a list of per-group strings, or a single AND-joined string when
|
|
178
|
+
oneline=True.
|
|
179
|
+
"""
|
|
180
|
+
groups = (definition or {}).get("condition_groups", [])
|
|
181
|
+
rendered = [" OR ".join(_format_condition(c, metric_map) for c in g.get("conditions", []))
|
|
182
|
+
for g in groups]
|
|
183
|
+
if oneline:
|
|
184
|
+
return " AND ".join(f"({g})" if " OR " in g else g for g in rendered)
|
|
185
|
+
return rendered
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _norm_name(s: str) -> str:
|
|
189
|
+
return re.sub(r"\s+", " ", (s or "").strip().lower())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CLI group: `klaviyo` command. Commands live in klaviyo_cli/commands/."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import entry_points
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .config import resolve_transport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_context(profile: str | None) -> dict:
|
|
11
|
+
"""Build ctx.obj auth pieces. Transport resolves lazily on first call."""
|
|
12
|
+
transport = None
|
|
13
|
+
|
|
14
|
+
def call(method, path, body=None, revision=None):
|
|
15
|
+
nonlocal transport
|
|
16
|
+
if transport is None:
|
|
17
|
+
transport = resolve_transport(profile)
|
|
18
|
+
return transport.call(method, path, body=body, revision=revision)
|
|
19
|
+
|
|
20
|
+
return {"call": call, "label": profile or "default"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.option("--json", "use_json", is_flag=True, help="Output raw JSON")
|
|
25
|
+
@click.option("-p", "--profile", envvar="KLAVIYO_PROFILE", default=None,
|
|
26
|
+
help="Named account profile from ~/.config/klaviyo-cli/config.toml")
|
|
27
|
+
@click.version_option(package_name="klaviyo-cli")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def main(ctx, use_json, profile):
|
|
30
|
+
"""Unofficial Klaviyo CLI: campaigns, segments, flows, metrics, scheduling.
|
|
31
|
+
|
|
32
|
+
Auth: set KLAVIYO_API_KEY, or use --profile with a config file.
|
|
33
|
+
Not affiliated with or endorsed by Klaviyo, Inc.
|
|
34
|
+
"""
|
|
35
|
+
ctx.obj = {"json": use_json, **build_context(profile)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def entry():
|
|
39
|
+
"""Console-script entry. Host packages (klaviyo_cli.hosts entry points)
|
|
40
|
+
may supply a replacement group (e.g. an agency wrapper with per-client auth)."""
|
|
41
|
+
for ep in entry_points(group="klaviyo_cli.hosts"):
|
|
42
|
+
group = ep.load()()
|
|
43
|
+
if group is not None:
|
|
44
|
+
return group()
|
|
45
|
+
return main()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
from . import commands # noqa: E402,F401 (registers subcommands on `main`)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Import command modules so they register on the main group."""
|
|
2
|
+
from . import campaigns # noqa: F401
|
|
3
|
+
from . import segments # noqa: F401
|
|
4
|
+
from . import flows # noqa: F401
|
|
5
|
+
from . import metrics # noqa: F401
|
|
6
|
+
from . import profiles # noqa: F401
|
|
7
|
+
from . import events # noqa: F401
|
|
8
|
+
from . import sms # noqa: F401
|
|
9
|
+
from . import raw # noqa: F401
|