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.
@@ -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
+ )