discolike-cli 0.6.2__tar.gz → 0.6.3__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.
Files changed (50) hide show
  1. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/PKG-INFO +6 -2
  2. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/README.md +4 -0
  3. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/pyproject.toml +2 -2
  4. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_help.py +1 -1
  5. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/bulk.py +3 -0
  6. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/contacts.py +7 -0
  7. discolike_cli-0.6.3/src/discolike_cli/contacts_run.py +169 -0
  8. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/discover.py +5 -0
  9. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/email.py +14 -1
  10. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/main.py +2 -0
  11. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_bulk_cli.py +13 -0
  12. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_company.py +3 -3
  13. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_contacts_cli.py +1 -1
  14. discolike_cli-0.6.3/tests/test_contacts_run_cli.py +209 -0
  15. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_discover.py +18 -0
  16. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_sdk_parity.py +5 -2
  17. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/.gitignore +0 -0
  18. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/LICENSE +0 -0
  19. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/__init__.py +0 -0
  20. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_inputs.py +0 -0
  21. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_loopback.py +0 -0
  22. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_output.py +0 -0
  23. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/account.py +0 -0
  24. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/auth.py +0 -0
  25. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/company.py +0 -0
  26. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/discogen.py +0 -0
  27. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/enrich.py +0 -0
  28. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/match.py +0 -0
  29. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/prospecting.py +0 -0
  30. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/providers.py +0 -0
  31. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/py.typed +0 -0
  32. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/queries.py +0 -0
  33. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/signup.py +0 -0
  34. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/conftest.py +0 -0
  35. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_account.py +0 -0
  36. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_auth.py +0 -0
  37. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_auth_oauth.py +0 -0
  38. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_discogen_cli.py +0 -0
  39. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_email_cli.py +0 -0
  40. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_enrich_cli.py +0 -0
  41. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_entrypoint.py +0 -0
  42. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_file_inputs_cli.py +0 -0
  43. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_help.py +0 -0
  44. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_inputs.py +0 -0
  45. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_match_cli.py +0 -0
  46. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_output.py +0 -0
  47. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_prospecting_cli.py +0 -0
  48. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_providers_cli.py +0 -0
  49. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_queries_cli.py +0 -0
  50. {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_signup_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: discolike-cli
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Official CLI for the DiscoLike API
5
5
  Project-URL: Homepage, https://www.discolike.com
6
6
  Project-URL: Documentation, https://docs.discolike.com
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.15
25
25
  Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.11
28
- Requires-Dist: discolike==0.6.2
28
+ Requires-Dist: discolike==0.6.3
29
29
  Requires-Dist: rich>=13.0
30
30
  Requires-Dist: typer<0.28,>=0.26.1
31
31
  Description-Content-Type: text/markdown
@@ -69,6 +69,10 @@ discolike extract https://stripe.com/enterprise
69
69
 
70
70
  Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
71
71
 
72
+ ### Contacts runs
73
+
74
+ `discolike contacts run create --params-file run.json` starts a contacts run from a JSON request (`companies`, `people`, `relevance`, `per_company`, `max_contacts`, `auto_stop`, `find_missing`, `email_cap_usd`, `mode`). `get`, `list`, `continue`, `cancel` and `title-preview` mirror the SDK. `wait RUN_ID` blocks until the run finishes or pauses after a step; `--follow` continues each step for you and stops before `find_emails` unless the run was created with a cap or you pass `--email-cap-usd`. `contacts search`, `count` and `generate` still work but print a deprecation line on stderr after their output: their endpoints sunset on 2026-12-31. `email find --suggested-email` verifies an address you already have before looking for the right one.
75
+
72
76
  ### Volume pulls
73
77
 
74
78
  `discolike bulk` walks past the 10,000-per-search ceiling and pulls contacts for a whole domain list into one CSV, with checkpoint and resume:
@@ -37,6 +37,10 @@ discolike extract https://stripe.com/enterprise
37
37
 
38
38
  Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
39
39
 
40
+ ### Contacts runs
41
+
42
+ `discolike contacts run create --params-file run.json` starts a contacts run from a JSON request (`companies`, `people`, `relevance`, `per_company`, `max_contacts`, `auto_stop`, `find_missing`, `email_cap_usd`, `mode`). `get`, `list`, `continue`, `cancel` and `title-preview` mirror the SDK. `wait RUN_ID` blocks until the run finishes or pauses after a step; `--follow` continues each step for you and stops before `find_emails` unless the run was created with a cap or you pass `--email-cap-usd`. `contacts search`, `count` and `generate` still work but print a deprecation line on stderr after their output: their endpoints sunset on 2026-12-31. `email find --suggested-email` verifies an address you already have before looking for the right one.
43
+
40
44
  ### Volume pulls
41
45
 
42
46
  `discolike bulk` walks past the 10,000-per-search ceiling and pulls contacts for a whole domain list into one CSV, with checkpoint and resume:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "discolike-cli"
7
- version = "0.6.2"
7
+ version = "0.6.3"
8
8
  description = "Official CLI for the DiscoLike API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,7 +12,7 @@ license-files = ["LICENSE"]
12
12
  requires-python = ">=3.11"
13
13
  authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
14
14
  dependencies = [
15
- "discolike==0.6.2",
15
+ "discolike==0.6.3",
16
16
  "typer>=0.26.1,<0.28",
17
17
  "rich>=13.0",
18
18
  ]
@@ -185,7 +185,7 @@ Output (success, exit 0):
185
185
  """,
186
186
  "extract": """\
187
187
  Output (success, exit 0):
188
- {"text": <string>, "language": <ISO 639-1>}
188
+ {"text": <string>}
189
189
  Fetches the page live; use it to confirm a seed domain is the right company.
190
190
 
191
191
  Common errors:
@@ -41,6 +41,7 @@ from discolike_cli.discover import DOMAINS_FILE_HELP
41
41
  from discolike_cli.discover import PARAM_HELP
42
42
  from discolike_cli.discover import PARAMS_FILE_HELP
43
43
  from discolike_cli.discover import RELEVANCE_HELP
44
+ from discolike_cli.discover import VALID_MX_HELP
44
45
  from discolike_cli.discover import VARIANCE_HELP
45
46
  from discolike_cli.discover import _merge_params
46
47
 
@@ -159,6 +160,7 @@ def companies_command(
159
160
  state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."),
160
161
  employee_range: str | None = typer.Option(None, help="Employee count range filter."),
161
162
  revenue_range: str | None = typer.Option(None, help="Revenue range filter."),
163
+ valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
162
164
  relevance: str | None = typer.Option(None, help=RELEVANCE_HELP),
163
165
  variance: str | None = typer.Option(None, help=VARIANCE_HELP),
164
166
  exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
@@ -187,6 +189,7 @@ def companies_command(
187
189
  state=state,
188
190
  employee_range=employee_range,
189
191
  revenue_range=revenue_range,
192
+ valid_mx=valid_mx,
190
193
  relevance=relevance,
191
194
  variance=variance,
192
195
  ),
@@ -59,6 +59,11 @@ MAX_COMPANIES_HELP = "Maximum number of enriched companies to return; cannot be
59
59
  RESULTS_BY_COMPANY_HELP = "Maximum contacts per company domain (default 5; 0 removes the cap)."
60
60
  INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)."
61
61
  CONSENSUS_HELP = "Number of query vectors to combine for consensus search."
62
+ # Printed only after success, so a failed command's stderr stays the single JSON error object.
63
+ DEPRECATED_COMMAND_WARNING = (
64
+ "warning: `discolike contacts {command}` is deprecated and its endpoint sunsets on 2026-12-31; "
65
+ "use `discolike contacts run`."
66
+ )
62
67
 
63
68
 
64
69
  def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bool | None) -> bool | None:
@@ -174,6 +179,7 @@ def search_command(
174
179
  consensus=consensus,
175
180
  )
176
181
  emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt)
182
+ typer.echo(DEPRECATED_COMMAND_WARNING.format(command="search"), err=True)
177
183
 
178
184
 
179
185
  @app.command("count")
@@ -510,3 +516,4 @@ def generate_command(
510
516
  ),
511
517
  )
512
518
  run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt)
519
+ typer.echo(DEPRECATED_COMMAND_WARNING.format(command="generate"), err=True)
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ import typer
6
+
7
+ from discolike.requests import ContactsRunEventsParams
8
+ from discolike.requests import ContactsRunGetParams
9
+ from discolike.requests import ContactsRunListParams
10
+ from discolike.requests import ContactsRunRequest
11
+ from discolike.requests import ContinueRunRequest
12
+ from discolike.requests import TitlePreviewRequest
13
+ from discolike_cli._output import build_request
14
+ from discolike_cli._output import emit
15
+ from discolike_cli._output import handle_errors
16
+ from discolike_cli.discover import _merge_params
17
+
18
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 3600.0
19
+
20
+ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
21
+ PARAMS_FILE_HELP = (
22
+ "JSON object with the run request: companies (source prompt, domains or lookalike), relevance, "
23
+ "people.fit, max_companies, per_company, email_cap_usd, exclusions and create_key. --param and flags override its values."
24
+ )
25
+ PARAM_HELP = "Extra key=value body field (repeatable)."
26
+ EMAIL_CAP_HELP = "Maximum USD to spend on validated delivery; omit for no cap."
27
+ GENERATION_HELP = "Generation of the companies run you are continuing; fetched when omitted."
28
+
29
+ app = typer.Typer(help="Find and validate contacts automatically. Poll company progress and delivery totals.")
30
+
31
+
32
+ @app.command("create")
33
+ @handle_errors
34
+ def create_command(
35
+ ctx: typer.Context,
36
+ params_file: pathlib.Path | None = typer.Option(None, help=PARAMS_FILE_HELP),
37
+ param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
38
+ email_cap_usd: float | None = typer.Option(None, "--email-cap-usd", help=EMAIL_CAP_HELP),
39
+ ) -> None:
40
+ """Start a contacts run from a JSON request."""
41
+ from discolike_cli.main import get_client
42
+
43
+ request = build_request(ContactsRunRequest, _merge_params(param, params_file, email_cap_usd=email_cap_usd))
44
+ emit(get_client(ctx).contacts_run.create(request))
45
+
46
+
47
+ @app.command("list")
48
+ @handle_errors
49
+ def list_command(
50
+ ctx: typer.Context,
51
+ before: str | None = typer.Option(
52
+ None, help="Only runs created before this time, ISO 8601 with offset, e.g. 2026-10-30T12:00:00Z."
53
+ ),
54
+ limit: int | None = typer.Option(None, help="Runs per page (default 20, maximum 100)."),
55
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
56
+ ) -> None:
57
+ """List your runs, newest first."""
58
+ from discolike_cli.main import get_client
59
+
60
+ params = build_request(ContactsRunListParams, _merge_params(None, before=before, limit=limit))
61
+ emit(get_client(ctx).contacts_run.list(params), fmt=fmt)
62
+
63
+
64
+ @app.command("get")
65
+ @handle_errors
66
+ def get_command(
67
+ ctx: typer.Context,
68
+ run_id: str = typer.Argument(..., help="Run ID."),
69
+ rows_offset: int | None = typer.Option(None, help="Company domains to skip for contacts runs."),
70
+ rows_limit: int | None = typer.Option(
71
+ None, help="Company domains per page for contacts runs (default 100, maximum 1000)."
72
+ ),
73
+ all_rows: bool = typer.Option(False, "--all-rows", help="Print every row of the run as a list instead."),
74
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
75
+ ) -> None:
76
+ """Show a run with a page of its rows."""
77
+ from discolike_cli.main import get_client
78
+
79
+ client = get_client(ctx)
80
+ if all_rows:
81
+ emit(client.contacts_run.rows(run_id), fmt=fmt)
82
+ return
83
+ params = build_request(ContactsRunGetParams, _merge_params(None, rows_offset=rows_offset, rows_limit=rows_limit))
84
+ emit(client.contacts_run.get(run_id, params))
85
+
86
+
87
+ @app.command("continue")
88
+ @handle_errors
89
+ def continue_command(
90
+ ctx: typer.Context,
91
+ run_id: str = typer.Argument(..., help="Run ID."),
92
+ generation: int | None = typer.Option(None, help=GENERATION_HELP),
93
+ redo: bool | None = typer.Option(None, "--redo/--no-redo", help="Run the current step again."),
94
+ ) -> None:
95
+ """Advance or redo a companies run. Contacts runs proceed automatically (409 here)."""
96
+ from discolike_cli.main import get_client
97
+
98
+ client = get_client(ctx)
99
+ request = build_request(
100
+ ContinueRunRequest,
101
+ _merge_params(
102
+ None,
103
+ generation=client.contacts_run.get(run_id).generation if generation is None else generation,
104
+ redo=redo,
105
+ ),
106
+ )
107
+ emit(client.contacts_run.continue_(run_id, request))
108
+
109
+
110
+ @app.command("cancel")
111
+ @handle_errors
112
+ def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID.")) -> None:
113
+ """Cancel a run; charges already incurred stay."""
114
+ from discolike_cli.main import get_client
115
+
116
+ emit(get_client(ctx).contacts_run.cancel(run_id))
117
+
118
+
119
+ @app.command("wait")
120
+ @handle_errors
121
+ def wait_command(
122
+ ctx: typer.Context,
123
+ run_id: str = typer.Argument(..., help="Run ID."),
124
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help="Max seconds to wait."),
125
+ ) -> None:
126
+ """Block until done, failed or cancelled. A timeout stops local polling only."""
127
+ from discolike_cli.main import get_client
128
+
129
+ emit(get_client(ctx).contacts_run.wait(run_id, max_wait=timeout))
130
+
131
+
132
+ @app.command("events")
133
+ @handle_errors
134
+ def events_command(
135
+ ctx: typer.Context,
136
+ run_id: str = typer.Argument(..., help="Run ID."),
137
+ after: int | None = typer.Option(None, help="Return events after this cursor."),
138
+ limit: int | None = typer.Option(None, help="Events per page (default 100, maximum 500)."),
139
+ ) -> None:
140
+ """Read company progress events and the next cursor."""
141
+ from discolike_cli.main import get_client
142
+
143
+ params = build_request(ContactsRunEventsParams, _merge_params(None, after=after, limit=limit))
144
+ emit(get_client(ctx).contacts_run.events(run_id, params))
145
+
146
+
147
+ @app.command("title-preview")
148
+ @handle_errors
149
+ def title_preview_command(
150
+ ctx: typer.Context,
151
+ title: list[str] = typer.Option(..., "--title", help="Job title to preview (repeatable)."),
152
+ exclude_title: list[str] | None = typer.Option(
153
+ None, "--exclude-title", help="Job title to leave out (repeatable)."
154
+ ),
155
+ match: str = typer.Option("similar", help="similar also accepts close variants of a title; exact does not."),
156
+ relevance: str | None = typer.Option(None, help="max_relevance, balanced or max_coverage."),
157
+ auto_stop: bool | None = typer.Option(None, "--auto-stop/--no-auto-stop", help="Apply the relevance threshold."),
158
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
159
+ ) -> None:
160
+ """Preview the titles a people spec matches, with role scores and contact counts."""
161
+ from discolike_cli.main import get_client
162
+
163
+ request = build_request(
164
+ TitlePreviewRequest,
165
+ _merge_params(
166
+ None, titles=title, exclude_titles=exclude_title, match=match, relevance=relevance, auto_stop=auto_stop
167
+ ),
168
+ )
169
+ emit(get_client(ctx).contacts_run.title_preview(request).titles, fmt=fmt)
@@ -42,6 +42,7 @@ NEGATE_SOCIAL_HELP = "Exclude companies with this social profile (repeatable); s
42
42
  LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)."
43
43
  NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language."
44
44
  EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)."
45
+ VALID_MX_HELP = "Exclude companies with no mail server (off by default)."
45
46
  COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)."
46
47
  RELEVANCE_HELP = (
47
48
  "Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). "
@@ -114,6 +115,7 @@ def discover_command(
114
115
  consensus: int | None = typer.Option(
115
116
  None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches."
116
117
  ),
118
+ valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
117
119
  redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
118
120
  exclude_leadgen: bool | None = typer.Option(
119
121
  None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP
@@ -193,6 +195,7 @@ def discover_command(
193
195
  relevance=relevance,
194
196
  variance=variance,
195
197
  consensus=consensus,
198
+ valid_mx=valid_mx,
196
199
  redirect=redirect,
197
200
  exclude_leadgen=exclude_leadgen,
198
201
  retrieval=retrieval,
@@ -245,6 +248,7 @@ def count_command(
245
248
  negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."),
246
249
  min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."),
247
250
  max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."),
251
+ valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
248
252
  redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
249
253
  exclude_leadgen: bool | None = typer.Option(
250
254
  None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP
@@ -291,6 +295,7 @@ def count_command(
291
295
  negate_tech_stack=negate_tech_stack,
292
296
  min_digital_footprint=min_digital_footprint,
293
297
  max_digital_footprint=max_digital_footprint,
298
+ valid_mx=valid_mx,
294
299
  redirect=redirect,
295
300
  exclude_leadgen=exclude_leadgen,
296
301
  ),
@@ -86,6 +86,12 @@ def find_command(
86
86
  known_pattern: str | None = typer.Option(
87
87
  None, "--known-pattern", help="Known email local-part pattern for this domain, e.g. first.last."
88
88
  ),
89
+ suggested_email: str | None = typer.Option(
90
+ None,
91
+ "--suggested-email",
92
+ help="Address you think is right: verified first, and the right one recovered if it is not proven. "
93
+ "Must be at DOMAIN or the company's mail domain.",
94
+ ),
89
95
  wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
90
96
  timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
91
97
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
@@ -95,7 +101,14 @@ def find_command(
95
101
 
96
102
  request = build_request(
97
103
  FindEmailRequest,
98
- _merge_params(None, first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern),
104
+ _merge_params(
105
+ None,
106
+ first_name=first_name,
107
+ last_name=last_name,
108
+ domain=domain,
109
+ known_pattern=known_pattern,
110
+ suggested_email=suggested_email,
111
+ ),
99
112
  )
100
113
  job = get_client(ctx).email.find(request)
101
114
  if not wait:
@@ -18,6 +18,7 @@ from discolike_cli import auth
18
18
  from discolike_cli import bulk
19
19
  from discolike_cli import company
20
20
  from discolike_cli import contacts
21
+ from discolike_cli import contacts_run
21
22
  from discolike_cli import discogen
22
23
  from discolike_cli import discover
23
24
  from discolike_cli import email
@@ -83,6 +84,7 @@ app.add_typer(auth.app, name="auth")
83
84
  app.add_typer(bulk.app, name="bulk")
84
85
  app.add_typer(company.app, name="company")
85
86
  app.add_typer(contacts.app, name="contacts")
87
+ contacts.app.add_typer(contacts_run.app, name="run")
86
88
  app.add_typer(discogen.app, name="discogen")
87
89
  app.add_typer(email.app, name="email")
88
90
  app.add_typer(queries.app, name="queries")
@@ -661,3 +661,16 @@ def test_bulk_rejects_non_positive_rate_limit(
661
661
  result = runner.invoke(app, ["bulk", *command, *extra, "--out", str(tmp_path / "c.csv"), "--rate-limit", "0"])
662
662
  assert result.exit_code == 2, result.output
663
663
  assert recorder.requests == []
664
+
665
+
666
+ @pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")])
667
+ def test_bulk_companies_valid_mx(
668
+ install_build_client: Callable[[Handler], None], tmp_path: Path, flags: list[str], expected: str | None
669
+ ) -> None:
670
+ recorder = Recorder(discover=[_companies("a", 1)])
671
+ install_build_client(recorder)
672
+ result = runner.invoke(
673
+ app, ["bulk", "companies", "--out", str(tmp_path / "companies.csv"), "--max-companies", "1", *flags]
674
+ )
675
+ assert result.exit_code == 0, result.output
676
+ assert recorder.params("/v1/discover")[0].get("valid_mx") == expected
@@ -135,7 +135,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[
135
135
 
136
136
  def handler(request: httpx2.Request) -> httpx2.Response:
137
137
  captured["request"] = request
138
- return httpx2.Response(200, json={"text": "hello", "language": "en"})
138
+ return httpx2.Response(200, json={"text": "hello"})
139
139
 
140
140
  install_build_client(handler)
141
141
  result = runner.invoke(app, ["extract", "https://acme.com/about"])
@@ -143,7 +143,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[
143
143
  request = captured["request"]
144
144
  assert request.url.path == "/v1/extract"
145
145
  assert request.url.params.get("url") == "https://acme.com/about"
146
- assert json.loads(result.stdout) == {"text": "hello", "language": "en"}
146
+ assert json.loads(result.stdout) == {"text": "hello"}
147
147
 
148
148
 
149
149
  def test_extract_domain_option_replaces_url(install_build_client: Callable[[Handler], None]) -> None:
@@ -151,7 +151,7 @@ def test_extract_domain_option_replaces_url(install_build_client: Callable[[Hand
151
151
 
152
152
  def handler(request: httpx2.Request) -> httpx2.Response:
153
153
  captured["request"] = request
154
- return httpx2.Response(200, json={"text": "hello", "language": "en"})
154
+ return httpx2.Response(200, json={"text": "hello"})
155
155
 
156
156
  install_build_client(handler)
157
157
  result = runner.invoke(app, ["extract", "--domain", "acme.com"])
@@ -532,7 +532,7 @@ def test_contacts_without_email_validated_does_not_warn(install_build_client: Ca
532
532
  install_build_client(lambda request: httpx2.Response(200, json=[]))
533
533
  result = runner.invoke(app, ["contacts", "search", "--name", "Jane"])
534
534
  assert result.exit_code == 0, result.output
535
- assert "deprecated" not in result.stderr
535
+ assert "email-validated" not in result.stderr
536
536
 
537
537
 
538
538
  def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None:
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import httpx2
10
+ import pytest
11
+ from typer.testing import CliRunner
12
+
13
+ from discolike_cli.main import app
14
+ from discolike_testkit import Handler
15
+
16
+ runner = CliRunner()
17
+
18
+ RUN_ID = "00000000-0000-0000-0000-0000000000bb"
19
+ RUN_PATH = f"/v1/contacts/run/{RUN_ID}"
20
+ TIMESTAMP = "2026-10-30T12:00:00Z"
21
+ STEPS = ("find_contacts", "find_missing", "review", "find_emails")
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
26
+ monkeypatch.setattr(time, "sleep", lambda seconds: None)
27
+
28
+
29
+ def run_payload(*, status: str = "waiting", step: int = 0, generation: int = 1) -> dict[str, Any]:
30
+ return {
31
+ "id": RUN_ID,
32
+ "kind": "contacts",
33
+ "status": status,
34
+ "current_step": STEPS[step],
35
+ "generation": generation,
36
+ "steps": {STEPS[step]: {"status": "done"}},
37
+ "created_at": TIMESTAMP,
38
+ "updated_at": TIMESTAMP,
39
+ "expires_at": TIMESTAMP,
40
+ }
41
+
42
+
43
+ def test_run_create_posts_params_file(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None:
44
+ bodies: list[dict[str, Any]] = []
45
+
46
+ def handler(request: httpx2.Request) -> httpx2.Response:
47
+ bodies.append(json.loads(request.content))
48
+ return httpx2.Response(202, json=run_payload(status="running"))
49
+
50
+ install_build_client(handler)
51
+ params_file = tmp_path / "run.json"
52
+ params_file.write_text(
53
+ json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"fit": "Owners"}})
54
+ )
55
+ result = runner.invoke(
56
+ app, ["contacts", "run", "create", "--params-file", str(params_file), "--email-cap-usd", "20"]
57
+ )
58
+
59
+ assert result.exit_code == 0, result.output
60
+ assert bodies == [
61
+ {
62
+ "companies": {"source": "prompt", "icp_text": "dental clinics"},
63
+ "people": {"fit": "Owners"},
64
+ "email_cap_usd": "20.0",
65
+ }
66
+ ]
67
+ assert json.loads(result.stdout)["status"] == "running"
68
+
69
+
70
+ def test_run_continue_fetches_generation_when_omitted(install_build_client: Callable[[Handler], None]) -> None:
71
+ seen: list[tuple[str, str, Any]] = []
72
+
73
+ def handler(request: httpx2.Request) -> httpx2.Response:
74
+ body = json.loads(request.content) if request.content else None
75
+ seen.append((request.method, request.url.path, body))
76
+ return httpx2.Response(200, json=run_payload(generation=4))
77
+
78
+ install_build_client(handler)
79
+ result = runner.invoke(app, ["contacts", "run", "continue", RUN_ID, "--redo"])
80
+
81
+ assert result.exit_code == 0, result.output
82
+ assert seen == [
83
+ ("GET", RUN_PATH, None),
84
+ ("POST", f"{RUN_PATH}/continue", {"generation": 4, "redo": True}),
85
+ ]
86
+
87
+
88
+ def test_run_wait_polls_automatically(install_build_client: Callable[[Handler], None]) -> None:
89
+ statuses = iter(["ready", "running", "done"])
90
+
91
+ def handler(request: httpx2.Request) -> httpx2.Response:
92
+ assert request.method == "GET"
93
+ return httpx2.Response(200, json=run_payload(status=next(statuses)))
94
+
95
+ install_build_client(handler)
96
+ result = runner.invoke(app, ["contacts", "run", "wait", RUN_ID])
97
+ assert result.exit_code == 0, result.output
98
+ assert json.loads(result.stdout)["status"] == "done"
99
+
100
+
101
+ def test_run_events(install_build_client: Callable[[Handler], None]) -> None:
102
+ def handler(request: httpx2.Request) -> httpx2.Response:
103
+ assert request.url.path == RUN_PATH + "/events"
104
+ assert dict(request.url.params) == {"after": "4", "limit": "10"}
105
+ return httpx2.Response(200, json={"events": [], "next_cursor": 5})
106
+
107
+ install_build_client(handler)
108
+ result = runner.invoke(app, ["contacts", "run", "events", RUN_ID, "--after", "4", "--limit", "10"])
109
+ assert result.exit_code == 0, result.output
110
+ assert json.loads(result.stdout)["next_cursor"] == 5
111
+
112
+
113
+ @pytest.mark.parametrize("flag", ["--follow", "--email-cap-usd"])
114
+ def test_removed_wait_flags(flag: str) -> None:
115
+ assert runner.invoke(app, ["contacts", "run", "wait", RUN_ID, flag]).exit_code == 2
116
+
117
+
118
+ def test_run_cancel_and_get(install_build_client: Callable[[Handler], None]) -> None:
119
+ seen: list[tuple[str, str, str]] = []
120
+
121
+ def handler(request: httpx2.Request) -> httpx2.Response:
122
+ seen.append((request.method, request.url.path, request.url.query.decode()))
123
+ return httpx2.Response(200, json=run_payload(status="cancelled"))
124
+
125
+ install_build_client(handler)
126
+ assert runner.invoke(app, ["contacts", "run", "cancel", RUN_ID]).exit_code == 0
127
+ assert runner.invoke(app, ["contacts", "run", "get", RUN_ID, "--rows-limit", "10"]).exit_code == 0
128
+ assert seen == [("POST", f"{RUN_PATH}/cancel", ""), ("GET", RUN_PATH, "rows_limit=10")]
129
+
130
+
131
+ def test_run_list_sends_before(install_build_client: Callable[[Handler], None]) -> None:
132
+ queries: list[httpx2.QueryParams] = []
133
+
134
+ def handler(request: httpx2.Request) -> httpx2.Response:
135
+ queries.append(request.url.params)
136
+ return httpx2.Response(200, json=[run_payload()])
137
+
138
+ install_build_client(handler)
139
+ result = runner.invoke(app, ["contacts", "run", "list", "--before", "2026-10-30T00:00:00+00:00", "--limit", "5"])
140
+ assert result.exit_code == 0, result.output
141
+ assert queries[0]["before"] == "2026-10-30T00:00:00Z"
142
+ assert queries[0]["limit"] == "5"
143
+
144
+
145
+ def test_run_title_preview(install_build_client: Callable[[Handler], None]) -> None:
146
+ bodies: list[dict[str, Any]] = []
147
+
148
+ def handler(request: httpx2.Request) -> httpx2.Response:
149
+ bodies.append(json.loads(request.content))
150
+ return httpx2.Response(200, json={"titles": [{"title": "Owner", "role": 1.0, "count": 3}]})
151
+
152
+ install_build_client(handler)
153
+ result = runner.invoke(app, ["contacts", "run", "title-preview", "--title", "Owner", "--format", "json"])
154
+ assert result.exit_code == 0, result.output
155
+ assert bodies == [{"titles": ["Owner"], "match": "similar"}]
156
+ assert json.loads(result.stdout) == [{"title": "Owner", "role": 1.0, "count": 3}]
157
+
158
+
159
+ def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None]) -> None:
160
+ install_build_client(lambda request: httpx2.Response(200, json=[]))
161
+ result = runner.invoke(app, ["contacts", "search", "--name", "Jane"])
162
+ assert result.exit_code == 0, result.output
163
+ assert "`discolike contacts search` is deprecated" in result.stderr
164
+ assert "use `discolike contacts run`" in result.stderr
165
+
166
+
167
+ def test_contacts_discover_prints_no_deprecation(install_build_client: Callable[[Handler], None]) -> None:
168
+ install_build_client(lambda request: httpx2.Response(200, json={"results": {}}))
169
+ result = runner.invoke(app, ["contacts", "discover", "--domain", "acme.com"])
170
+ assert result.exit_code == 0, result.output
171
+ assert "deprecated" not in result.stderr
172
+
173
+
174
+ def test_email_find_sends_suggested_email(install_build_client: Callable[[Handler], None]) -> None:
175
+ bodies: list[dict[str, Any]] = []
176
+
177
+ def handler(request: httpx2.Request) -> httpx2.Response:
178
+ bodies.append(json.loads(request.content))
179
+ return httpx2.Response(202, json={"job_id": "j-1", "status": "queued"})
180
+
181
+ install_build_client(handler)
182
+ result = runner.invoke(app, ["email", "find", "Grace", "Hopper", "navy.mil", "--suggested-email", "grace@navy.mil"])
183
+ assert result.exit_code == 0, result.output
184
+ assert bodies == [
185
+ {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"}
186
+ ]
187
+
188
+
189
+ def test_run_insufficient_balance(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None:
190
+ install_build_client(
191
+ lambda _: httpx2.Response(
192
+ 403,
193
+ json={
194
+ "detail": {
195
+ "code": "INSUFFICIENT_BALANCE",
196
+ "message": "Top up to continue",
197
+ "remaining_usd": 1,
198
+ "max_affordable_records": 2,
199
+ }
200
+ },
201
+ )
202
+ )
203
+ params = tmp_path / "run.json"
204
+ params.write_text(json.dumps({"companies": {"source": "domains", "domains": ["acme.com"]}}))
205
+ result = runner.invoke(app, ["contacts", "run", "create", "--params-file", str(params)])
206
+ assert result.exit_code == 3
207
+ error = json.loads(result.stderr)
208
+ assert error["code"] == "insufficient_balance"
209
+ assert error["max_affordable_records"] == 2
@@ -4,6 +4,7 @@ import json
4
4
  from collections.abc import Callable
5
5
 
6
6
  import httpx2
7
+ import pytest
7
8
  from typer.testing import CliRunner
8
9
 
9
10
  from discolike_cli.main import app
@@ -411,3 +412,20 @@ def test_discover_unauthorized_exits_3(install_build_client: Callable[[Handler],
411
412
  assert result.exit_code == 3
412
413
  payload = json.loads(result.stderr)
413
414
  assert payload["error"] == "AuthenticationError"
415
+
416
+
417
+ @pytest.mark.parametrize("command", ["discover", "count"])
418
+ @pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")])
419
+ def test_valid_mx_flag(
420
+ install_build_client: Callable[[Handler], None], command: str, flags: list[str], expected: str | None
421
+ ) -> None:
422
+ seen: list[httpx2.QueryParams] = []
423
+
424
+ def handler(request: httpx2.Request) -> httpx2.Response:
425
+ seen.append(request.url.params)
426
+ return _count_ok(request) if command == "count" else _discover_ok(request)
427
+
428
+ install_build_client(handler)
429
+ result = runner.invoke(app, [command, *flags])
430
+ assert result.exit_code == 0, result.output
431
+ assert seen[0].get("valid_mx") == expected
@@ -29,7 +29,10 @@ DELIBERATELY_OMITTED: dict[str, frozenset[str]] = {
29
29
  }
30
30
  # ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging
31
31
  # fields itself; its flags are the handful a volume run needs, not one per SDK field.
32
- PARAMS_FILE_SITES = frozenset({"bulk.companies_command", "bulk.estimate_command", "bulk.contacts_command"})
32
+ # ``contacts run create`` takes its nested companies/people request the same way.
33
+ PARAMS_FILE_SITES = frozenset(
34
+ {"bulk.companies_command", "bulk.estimate_command", "bulk.contacts_command", "contacts_run.create_command"}
35
+ )
33
36
  DICT_ONLY_FIELDS: dict[str, frozenset[str]] = {
34
37
  "ContactGenerateRequest": frozenset({"initial_contact_counts"}),
35
38
  "DiscoGenProcessRequest": frozenset({"previous_discogen_data"}),
@@ -97,7 +100,7 @@ def test_cli_forwards_every_sdk_field(site: str, model_name: str, forwarded: set
97
100
 
98
101
 
99
102
  def test_every_request_model_has_a_cli_command() -> None:
100
- used = {model_name for _, model_name, _ in SITES}
103
+ used = {model_name for _, model_name, _ in _build_request_sites()}
101
104
  declared = {
102
105
  name
103
106
  for name in dir(requests_module)
File without changes
File without changes