foxreach-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foxreach_cli/__init__.py +3 -0
- foxreach_cli/client_factory.py +22 -0
- foxreach_cli/commands/__init__.py +0 -0
- foxreach_cli/commands/accounts.py +87 -0
- foxreach_cli/commands/analytics.py +69 -0
- foxreach_cli/commands/campaigns.py +218 -0
- foxreach_cli/commands/inbox.py +123 -0
- foxreach_cli/commands/leads.py +178 -0
- foxreach_cli/commands/sequences.py +126 -0
- foxreach_cli/commands/templates.py +133 -0
- foxreach_cli/config.py +63 -0
- foxreach_cli/main.py +92 -0
- foxreach_cli/output.py +66 -0
- foxreach_cli-0.1.0.dist-info/METADATA +145 -0
- foxreach_cli-0.1.0.dist-info/RECORD +18 -0
- foxreach_cli-0.1.0.dist-info/WHEEL +4 -0
- foxreach_cli-0.1.0.dist-info/entry_points.txt +2 -0
- foxreach_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from foxreach import FoxReachError, LeadCreate, LeadUpdate
|
|
10
|
+
|
|
11
|
+
from ..client_factory import get_client
|
|
12
|
+
from ..output import print_detail, print_error, print_json, print_meta, print_success, print_table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def leads() -> None:
|
|
17
|
+
"""Manage leads."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@leads.command("list")
|
|
21
|
+
@click.option("--search", default=None, help="Search by email, name, or company")
|
|
22
|
+
@click.option("--status", default=None, type=click.Choice(["active", "bounced", "unsubscribed", "replied"]))
|
|
23
|
+
@click.option("--page", default=1, type=int, help="Page number")
|
|
24
|
+
@click.option("--limit", default=50, type=int, help="Results per page")
|
|
25
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
26
|
+
def leads_list(search: Optional[str], status: Optional[str], page: int, limit: int, as_json: bool) -> None:
|
|
27
|
+
"""List leads."""
|
|
28
|
+
client = get_client()
|
|
29
|
+
try:
|
|
30
|
+
result = client.leads.list(page=page, page_size=limit, search=search, status=status)
|
|
31
|
+
except FoxReachError as e:
|
|
32
|
+
print_error(str(e))
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
if as_json:
|
|
36
|
+
print_json({"data": [asdict(l) for l in result.data], "meta": asdict(result.meta)})
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if not result.data:
|
|
40
|
+
click.echo("No leads found.")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
print_table(
|
|
44
|
+
result.data,
|
|
45
|
+
["ID", "Email", "First Name", "Last Name", "Company", "Status"],
|
|
46
|
+
{"ID": "id", "Email": "email", "First Name": "first_name", "Last Name": "last_name", "Company": "company", "Status": "status"},
|
|
47
|
+
)
|
|
48
|
+
print_meta(result.meta)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@leads.command("get")
|
|
52
|
+
@click.argument("lead_id")
|
|
53
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
54
|
+
def leads_get(lead_id: str, as_json: bool) -> None:
|
|
55
|
+
"""Get a lead by ID."""
|
|
56
|
+
client = get_client()
|
|
57
|
+
try:
|
|
58
|
+
lead = client.leads.get(lead_id)
|
|
59
|
+
except FoxReachError as e:
|
|
60
|
+
print_error(str(e))
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
if as_json:
|
|
64
|
+
print_json(lead)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
print_detail(lead, [
|
|
68
|
+
"id", "email", "first_name", "last_name", "company", "title",
|
|
69
|
+
"phone", "linkedin_url", "website", "status", "source",
|
|
70
|
+
"tags", "notes", "created_at", "updated_at",
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@leads.command("create")
|
|
75
|
+
@click.option("--email", required=True, help="Lead email address")
|
|
76
|
+
@click.option("--first-name", default=None, help="First name")
|
|
77
|
+
@click.option("--last-name", default=None, help="Last name")
|
|
78
|
+
@click.option("--company", default=None, help="Company name")
|
|
79
|
+
@click.option("--title", default=None, help="Job title")
|
|
80
|
+
@click.option("--phone", default=None, help="Phone number")
|
|
81
|
+
@click.option("--linkedin", default=None, help="LinkedIn URL")
|
|
82
|
+
@click.option("--website", default=None, help="Website URL")
|
|
83
|
+
@click.option("--notes", default=None, help="Notes")
|
|
84
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
85
|
+
def leads_create(
|
|
86
|
+
email: str,
|
|
87
|
+
first_name: Optional[str],
|
|
88
|
+
last_name: Optional[str],
|
|
89
|
+
company: Optional[str],
|
|
90
|
+
title: Optional[str],
|
|
91
|
+
phone: Optional[str],
|
|
92
|
+
linkedin: Optional[str],
|
|
93
|
+
website: Optional[str],
|
|
94
|
+
notes: Optional[str],
|
|
95
|
+
as_json: bool,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Create a new lead."""
|
|
98
|
+
client = get_client()
|
|
99
|
+
try:
|
|
100
|
+
lead = client.leads.create(LeadCreate(
|
|
101
|
+
email=email,
|
|
102
|
+
first_name=first_name,
|
|
103
|
+
last_name=last_name,
|
|
104
|
+
company=company,
|
|
105
|
+
title=title,
|
|
106
|
+
phone=phone,
|
|
107
|
+
linkedin_url=linkedin,
|
|
108
|
+
website=website,
|
|
109
|
+
notes=notes,
|
|
110
|
+
))
|
|
111
|
+
except FoxReachError as e:
|
|
112
|
+
print_error(str(e))
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
if as_json:
|
|
116
|
+
print_json(lead)
|
|
117
|
+
else:
|
|
118
|
+
print_success(f"Lead created: {lead.id}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@leads.command("update")
|
|
122
|
+
@click.argument("lead_id")
|
|
123
|
+
@click.option("--first-name", default=None, help="First name")
|
|
124
|
+
@click.option("--last-name", default=None, help="Last name")
|
|
125
|
+
@click.option("--company", default=None, help="Company name")
|
|
126
|
+
@click.option("--title", default=None, help="Job title")
|
|
127
|
+
@click.option("--phone", default=None, help="Phone number")
|
|
128
|
+
@click.option("--linkedin", default=None, help="LinkedIn URL")
|
|
129
|
+
@click.option("--website", default=None, help="Website URL")
|
|
130
|
+
@click.option("--notes", default=None, help="Notes")
|
|
131
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
132
|
+
def leads_update(
|
|
133
|
+
lead_id: str,
|
|
134
|
+
first_name: Optional[str],
|
|
135
|
+
last_name: Optional[str],
|
|
136
|
+
company: Optional[str],
|
|
137
|
+
title: Optional[str],
|
|
138
|
+
phone: Optional[str],
|
|
139
|
+
linkedin: Optional[str],
|
|
140
|
+
website: Optional[str],
|
|
141
|
+
notes: Optional[str],
|
|
142
|
+
as_json: bool,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Update a lead."""
|
|
145
|
+
client = get_client()
|
|
146
|
+
try:
|
|
147
|
+
lead = client.leads.update(lead_id, LeadUpdate(
|
|
148
|
+
first_name=first_name,
|
|
149
|
+
last_name=last_name,
|
|
150
|
+
company=company,
|
|
151
|
+
title=title,
|
|
152
|
+
phone=phone,
|
|
153
|
+
linkedin_url=linkedin,
|
|
154
|
+
website=website,
|
|
155
|
+
notes=notes,
|
|
156
|
+
))
|
|
157
|
+
except FoxReachError as e:
|
|
158
|
+
print_error(str(e))
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
if as_json:
|
|
162
|
+
print_json(lead)
|
|
163
|
+
else:
|
|
164
|
+
print_success(f"Lead updated: {lead.id}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@leads.command("delete")
|
|
168
|
+
@click.argument("lead_id")
|
|
169
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this lead?")
|
|
170
|
+
def leads_delete(lead_id: str) -> None:
|
|
171
|
+
"""Delete a lead."""
|
|
172
|
+
client = get_client()
|
|
173
|
+
try:
|
|
174
|
+
client.leads.delete(lead_id)
|
|
175
|
+
except FoxReachError as e:
|
|
176
|
+
print_error(str(e))
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
print_success(f"Lead deleted: {lead_id}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from foxreach import FoxReachError, SequenceCreate, SequenceUpdate
|
|
10
|
+
|
|
11
|
+
from ..client_factory import get_client
|
|
12
|
+
from ..output import print_detail, print_error, print_json, print_success, print_table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def sequences() -> None:
|
|
17
|
+
"""Manage campaign sequences (email steps)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@sequences.command("list")
|
|
21
|
+
@click.option("--campaign", required=True, help="Campaign ID")
|
|
22
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
23
|
+
def sequences_list(campaign: str, as_json: bool) -> None:
|
|
24
|
+
"""List sequences for a campaign."""
|
|
25
|
+
client = get_client()
|
|
26
|
+
try:
|
|
27
|
+
items = client.campaigns.sequences.list(campaign)
|
|
28
|
+
except FoxReachError as e:
|
|
29
|
+
print_error(str(e))
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
if as_json:
|
|
33
|
+
print_json([asdict(s) for s in items])
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if not items:
|
|
37
|
+
click.echo("No sequences found.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
print_table(
|
|
41
|
+
items,
|
|
42
|
+
["ID", "Step", "Subject", "Delay Days", "Type"],
|
|
43
|
+
{"ID": "id", "Step": "step_number", "Subject": "subject", "Delay Days": "delay_days", "Type": "template_type"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@sequences.command("create")
|
|
48
|
+
@click.option("--campaign", required=True, help="Campaign ID")
|
|
49
|
+
@click.option("--body", required=True, help="Email body (supports {{variables}})")
|
|
50
|
+
@click.option("--subject", default=None, help="Email subject")
|
|
51
|
+
@click.option("--delay-days", default=1, type=int, help="Days to wait before sending")
|
|
52
|
+
@click.option("--step", default=None, type=int, help="Step number")
|
|
53
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
54
|
+
def sequences_create(
|
|
55
|
+
campaign: str,
|
|
56
|
+
body: str,
|
|
57
|
+
subject: Optional[str],
|
|
58
|
+
delay_days: int,
|
|
59
|
+
step: Optional[int],
|
|
60
|
+
as_json: bool,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Add a sequence step to a campaign."""
|
|
63
|
+
client = get_client()
|
|
64
|
+
try:
|
|
65
|
+
seq = client.campaigns.sequences.create(campaign, SequenceCreate(
|
|
66
|
+
body=body,
|
|
67
|
+
subject=subject,
|
|
68
|
+
delay_days=delay_days,
|
|
69
|
+
step_number=step,
|
|
70
|
+
))
|
|
71
|
+
except FoxReachError as e:
|
|
72
|
+
print_error(str(e))
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
if as_json:
|
|
76
|
+
print_json(seq)
|
|
77
|
+
else:
|
|
78
|
+
print_success(f"Sequence created: {seq.id} (step {seq.step_number})")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@sequences.command("update")
|
|
82
|
+
@click.option("--campaign", required=True, help="Campaign ID")
|
|
83
|
+
@click.option("--sequence", "sequence_id", required=True, help="Sequence ID")
|
|
84
|
+
@click.option("--body", default=None, help="Email body")
|
|
85
|
+
@click.option("--subject", default=None, help="Email subject")
|
|
86
|
+
@click.option("--delay-days", default=None, type=int, help="Days to wait before sending")
|
|
87
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
88
|
+
def sequences_update(
|
|
89
|
+
campaign: str,
|
|
90
|
+
sequence_id: str,
|
|
91
|
+
body: Optional[str],
|
|
92
|
+
subject: Optional[str],
|
|
93
|
+
delay_days: Optional[int],
|
|
94
|
+
as_json: bool,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Update a sequence step."""
|
|
97
|
+
client = get_client()
|
|
98
|
+
try:
|
|
99
|
+
seq = client.campaigns.sequences.update(campaign, sequence_id, SequenceUpdate(
|
|
100
|
+
body=body,
|
|
101
|
+
subject=subject,
|
|
102
|
+
delay_days=delay_days,
|
|
103
|
+
))
|
|
104
|
+
except FoxReachError as e:
|
|
105
|
+
print_error(str(e))
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
if as_json:
|
|
109
|
+
print_json(seq)
|
|
110
|
+
else:
|
|
111
|
+
print_success(f"Sequence updated: {seq.id}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@sequences.command("delete")
|
|
115
|
+
@click.option("--campaign", required=True, help="Campaign ID")
|
|
116
|
+
@click.option("--sequence", "sequence_id", required=True, help="Sequence ID")
|
|
117
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this sequence?")
|
|
118
|
+
def sequences_delete(campaign: str, sequence_id: str) -> None:
|
|
119
|
+
"""Delete a sequence step."""
|
|
120
|
+
client = get_client()
|
|
121
|
+
try:
|
|
122
|
+
client.campaigns.sequences.delete(campaign, sequence_id)
|
|
123
|
+
except FoxReachError as e:
|
|
124
|
+
print_error(str(e))
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
print_success(f"Sequence deleted: {sequence_id}")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from foxreach import FoxReachError, TemplateCreate, TemplateUpdate
|
|
10
|
+
|
|
11
|
+
from ..client_factory import get_client
|
|
12
|
+
from ..output import print_detail, print_error, print_json, print_meta, print_success, print_table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def templates() -> None:
|
|
17
|
+
"""Manage email templates."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@templates.command("list")
|
|
21
|
+
@click.option("--page", default=1, type=int)
|
|
22
|
+
@click.option("--limit", default=50, type=int)
|
|
23
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
24
|
+
def templates_list(page: int, limit: int, as_json: bool) -> None:
|
|
25
|
+
"""List templates."""
|
|
26
|
+
client = get_client()
|
|
27
|
+
try:
|
|
28
|
+
result = client.templates.list(page=page, page_size=limit)
|
|
29
|
+
except FoxReachError as e:
|
|
30
|
+
print_error(str(e))
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
if as_json:
|
|
34
|
+
print_json({"data": [asdict(t) for t in result.data], "meta": asdict(result.meta)})
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if not result.data:
|
|
38
|
+
click.echo("No templates found.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
print_table(
|
|
42
|
+
result.data,
|
|
43
|
+
["ID", "Name", "Subject", "Type", "Created At"],
|
|
44
|
+
{"ID": "id", "Name": "name", "Subject": "subject", "Type": "template_type", "Created At": "created_at"},
|
|
45
|
+
)
|
|
46
|
+
print_meta(result.meta)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@templates.command("get")
|
|
50
|
+
@click.argument("template_id")
|
|
51
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
52
|
+
def templates_get(template_id: str, as_json: bool) -> None:
|
|
53
|
+
"""Get a template by ID."""
|
|
54
|
+
client = get_client()
|
|
55
|
+
try:
|
|
56
|
+
template = client.templates.get(template_id)
|
|
57
|
+
except FoxReachError as e:
|
|
58
|
+
print_error(str(e))
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
if as_json:
|
|
62
|
+
print_json(template)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
print_detail(template, [
|
|
66
|
+
"id", "name", "subject", "body", "template_type",
|
|
67
|
+
"send_as_html", "created_at", "updated_at",
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@templates.command("create")
|
|
72
|
+
@click.option("--name", required=True, help="Template name")
|
|
73
|
+
@click.option("--body", required=True, help="Email body (supports {{variables}})")
|
|
74
|
+
@click.option("--subject", default=None, help="Email subject")
|
|
75
|
+
@click.option("--html", is_flag=True, help="Send as HTML")
|
|
76
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
77
|
+
def templates_create(name: str, body: str, subject: Optional[str], html: bool, as_json: bool) -> None:
|
|
78
|
+
"""Create a new template."""
|
|
79
|
+
client = get_client()
|
|
80
|
+
try:
|
|
81
|
+
template = client.templates.create(TemplateCreate(
|
|
82
|
+
name=name,
|
|
83
|
+
body=body,
|
|
84
|
+
subject=subject,
|
|
85
|
+
send_as_html=html,
|
|
86
|
+
))
|
|
87
|
+
except FoxReachError as e:
|
|
88
|
+
print_error(str(e))
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
if as_json:
|
|
92
|
+
print_json(template)
|
|
93
|
+
else:
|
|
94
|
+
print_success(f"Template created: {template.id}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@templates.command("update")
|
|
98
|
+
@click.argument("template_id")
|
|
99
|
+
@click.option("--name", default=None, help="Template name")
|
|
100
|
+
@click.option("--body", default=None, help="Email body")
|
|
101
|
+
@click.option("--subject", default=None, help="Email subject")
|
|
102
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
103
|
+
def templates_update(template_id: str, name: Optional[str], body: Optional[str], subject: Optional[str], as_json: bool) -> None:
|
|
104
|
+
"""Update a template."""
|
|
105
|
+
client = get_client()
|
|
106
|
+
try:
|
|
107
|
+
template = client.templates.update(template_id, TemplateUpdate(
|
|
108
|
+
name=name,
|
|
109
|
+
body=body,
|
|
110
|
+
subject=subject,
|
|
111
|
+
))
|
|
112
|
+
except FoxReachError as e:
|
|
113
|
+
print_error(str(e))
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
if as_json:
|
|
117
|
+
print_json(template)
|
|
118
|
+
else:
|
|
119
|
+
print_success(f"Template updated: {template.id}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@templates.command("delete")
|
|
123
|
+
@click.argument("template_id")
|
|
124
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this template?")
|
|
125
|
+
def templates_delete(template_id: str) -> None:
|
|
126
|
+
"""Delete a template."""
|
|
127
|
+
client = get_client()
|
|
128
|
+
try:
|
|
129
|
+
client.templates.delete(template_id)
|
|
130
|
+
except FoxReachError as e:
|
|
131
|
+
print_error(str(e))
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
print_success(f"Template deleted: {template_id}")
|
foxreach_cli/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
_CONFIG_DIR = Path.home() / ".foxreach"
|
|
9
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load() -> Dict[str, Any]:
|
|
13
|
+
if _CONFIG_FILE.exists():
|
|
14
|
+
return json.loads(_CONFIG_FILE.read_text())
|
|
15
|
+
return {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _save(data: Dict[str, Any]) -> None:
|
|
19
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
_CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_api_key() -> Optional[str]:
|
|
24
|
+
env = os.environ.get("FOXREACH_API_KEY")
|
|
25
|
+
if env:
|
|
26
|
+
return env
|
|
27
|
+
return _load().get("api_key")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_base_url() -> Optional[str]:
|
|
31
|
+
env = os.environ.get("FOXREACH_BASE_URL")
|
|
32
|
+
if env:
|
|
33
|
+
return env
|
|
34
|
+
return _load().get("base_url")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_api_key(key: str) -> None:
|
|
38
|
+
data = _load()
|
|
39
|
+
data["api_key"] = key
|
|
40
|
+
_save(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_base_url(url: str) -> None:
|
|
44
|
+
data = _load()
|
|
45
|
+
data["base_url"] = url
|
|
46
|
+
_save(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_all() -> Dict[str, Any]:
|
|
50
|
+
data = _load()
|
|
51
|
+
if os.environ.get("FOXREACH_API_KEY"):
|
|
52
|
+
data["api_key"] = os.environ["FOXREACH_API_KEY"]
|
|
53
|
+
data["_api_key_source"] = "env"
|
|
54
|
+
if os.environ.get("FOXREACH_BASE_URL"):
|
|
55
|
+
data["base_url"] = os.environ["FOXREACH_BASE_URL"]
|
|
56
|
+
data["_base_url_source"] = "env"
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mask_key(key: str) -> str:
|
|
61
|
+
if len(key) <= 8:
|
|
62
|
+
return key[:4] + "****"
|
|
63
|
+
return key[:4] + "*" * (len(key) - 8) + key[-4:]
|
foxreach_cli/main.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from foxreach import FoxReachError
|
|
8
|
+
|
|
9
|
+
from . import config as cfg
|
|
10
|
+
from .output import console, print_error, print_success
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(package_name="foxreach-cli")
|
|
15
|
+
def cli() -> None:
|
|
16
|
+
"""FoxReach CLI — manage your cold email outreach from the terminal."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Config ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
@cli.group()
|
|
22
|
+
def config() -> None:
|
|
23
|
+
"""Manage CLI configuration."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@config.command("set-key")
|
|
27
|
+
@click.option("--key", prompt="API Key", hide_input=True, help="Your FoxReach API key (otr_...)")
|
|
28
|
+
def config_set_key(key: str) -> None:
|
|
29
|
+
"""Save your API key."""
|
|
30
|
+
if not key.startswith("otr_"):
|
|
31
|
+
print_error('API key must start with "otr_"')
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
cfg.set_api_key(key)
|
|
34
|
+
print_success("API key saved.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@config.command("set-url")
|
|
38
|
+
@click.argument("url")
|
|
39
|
+
def config_set_url(url: str) -> None:
|
|
40
|
+
"""Override the API base URL."""
|
|
41
|
+
cfg.set_base_url(url)
|
|
42
|
+
print_success(f"Base URL set to {url}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@config.command("show")
|
|
46
|
+
def config_show() -> None:
|
|
47
|
+
"""Show current configuration."""
|
|
48
|
+
data = cfg.get_all()
|
|
49
|
+
key = data.get("api_key")
|
|
50
|
+
if key:
|
|
51
|
+
source = data.get("_api_key_source", "config file")
|
|
52
|
+
console.print(f"API Key: {cfg.mask_key(key)} [dim]({source})[/dim]")
|
|
53
|
+
else:
|
|
54
|
+
console.print("API Key: [dim]not set[/dim]")
|
|
55
|
+
|
|
56
|
+
url = data.get("base_url")
|
|
57
|
+
if url:
|
|
58
|
+
source = data.get("_base_url_source", "config file")
|
|
59
|
+
console.print(f"Base URL: {url} [dim]({source})[/dim]")
|
|
60
|
+
else:
|
|
61
|
+
console.print("Base URL: [dim]default[/dim]")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Register command groups ───────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
from .commands.leads import leads
|
|
67
|
+
from .commands.campaigns import campaigns
|
|
68
|
+
from .commands.sequences import sequences
|
|
69
|
+
from .commands.templates import templates
|
|
70
|
+
from .commands.accounts import accounts
|
|
71
|
+
from .commands.inbox import inbox
|
|
72
|
+
from .commands.analytics import analytics
|
|
73
|
+
|
|
74
|
+
cli.add_command(leads)
|
|
75
|
+
cli.add_command(campaigns)
|
|
76
|
+
cli.add_command(sequences)
|
|
77
|
+
cli.add_command(templates)
|
|
78
|
+
cli.add_command(accounts)
|
|
79
|
+
cli.add_command(inbox)
|
|
80
|
+
cli.add_command(analytics)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main() -> None:
|
|
84
|
+
try:
|
|
85
|
+
cli()
|
|
86
|
+
except FoxReachError as e:
|
|
87
|
+
print_error(str(e))
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
foxreach_cli/output.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
err_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_json(data: Any) -> None:
|
|
17
|
+
if hasattr(data, "__dataclass_fields__"):
|
|
18
|
+
data = asdict(data)
|
|
19
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_table(rows: Sequence[Any], columns: List[str], field_map: Optional[Dict[str, str]] = None) -> None:
|
|
23
|
+
"""Print a rich table. field_map maps column headers to dataclass field names."""
|
|
24
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
25
|
+
for col in columns:
|
|
26
|
+
table.add_column(col)
|
|
27
|
+
|
|
28
|
+
for row in rows:
|
|
29
|
+
values = []
|
|
30
|
+
for col in columns:
|
|
31
|
+
field = (field_map or {}).get(col, col.lower().replace(" ", "_"))
|
|
32
|
+
val = getattr(row, field, "") if hasattr(row, field) else ""
|
|
33
|
+
values.append(str(val) if val is not None else "")
|
|
34
|
+
table.add_row(*values)
|
|
35
|
+
|
|
36
|
+
console.print(table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_detail(obj: Any, fields: List[str]) -> None:
|
|
40
|
+
"""Print a single object as key-value pairs."""
|
|
41
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
42
|
+
table.add_column("Field", style="bold")
|
|
43
|
+
table.add_column("Value")
|
|
44
|
+
|
|
45
|
+
for field in fields:
|
|
46
|
+
val = getattr(obj, field, None)
|
|
47
|
+
label = field.replace("_", " ").title()
|
|
48
|
+
table.add_row(label, str(val) if val is not None else "-")
|
|
49
|
+
|
|
50
|
+
console.print(table)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_success(message: str) -> None:
|
|
54
|
+
console.print(f"[green]{message}[/green]")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_error(message: str) -> None:
|
|
58
|
+
err_console.print(f"[red]Error:[/red] {message}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_meta(meta: Any) -> None:
|
|
62
|
+
if meta and hasattr(meta, "total"):
|
|
63
|
+
console.print(
|
|
64
|
+
f"\n[dim]Page {meta.page}/{meta.total_pages} "
|
|
65
|
+
f"({meta.total} total)[/dim]"
|
|
66
|
+
)
|