discolike-cli 0.3.1__tar.gz → 0.3.2__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 (38) hide show
  1. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/PKG-INFO +2 -2
  2. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/pyproject.toml +2 -2
  3. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/company.py +10 -5
  4. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/contacts.py +149 -0
  5. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/discover.py +89 -0
  6. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/enrich.py +11 -3
  7. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/match.py +25 -1
  8. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_company.py +24 -0
  9. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_contacts_cli.py +162 -9
  10. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_discover.py +114 -0
  11. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_enrich_cli.py +37 -0
  12. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_match_cli.py +55 -0
  13. discolike_cli-0.3.2/tests/test_sdk_parity.py +99 -0
  14. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/.gitignore +0 -0
  15. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/LICENSE +0 -0
  16. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/README.md +0 -0
  17. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/__init__.py +0 -0
  18. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/_loopback.py +0 -0
  19. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/_output.py +0 -0
  20. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/account.py +0 -0
  21. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/auth.py +0 -0
  22. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/discogen.py +0 -0
  23. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/email.py +0 -0
  24. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/main.py +0 -0
  25. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/providers.py +0 -0
  26. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/py.typed +0 -0
  27. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/queries.py +0 -0
  28. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/src/discolike_cli/signup.py +0 -0
  29. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/conftest.py +0 -0
  30. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_account.py +0 -0
  31. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_auth.py +0 -0
  32. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_auth_oauth.py +0 -0
  33. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_discogen_cli.py +0 -0
  34. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_email_cli.py +0 -0
  35. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_output.py +0 -0
  36. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_providers_cli.py +0 -0
  37. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/tests/test_queries_cli.py +0 -0
  38. {discolike_cli-0.3.1 → discolike_cli-0.3.2}/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.3.1
3
+ Version: 0.3.2
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.14
25
25
  Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.10
28
- Requires-Dist: discolike==0.3.1
28
+ Requires-Dist: discolike==0.3.2
29
29
  Requires-Dist: rich>=13.0
30
30
  Requires-Dist: typer>=0.12
31
31
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "discolike-cli"
7
- version = "0.3.1"
7
+ version = "0.3.2"
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.10"
13
13
  authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
14
14
  dependencies = [
15
- "discolike==0.3.1",
15
+ "discolike==0.3.2",
16
16
  "typer>=0.12",
17
17
  "rich>=13.0",
18
18
  ]
@@ -134,12 +134,17 @@ def public_links(
134
134
  @handle_errors
135
135
  def extract_command(
136
136
  ctx: typer.Context,
137
- url: str = typer.Argument(..., help="Page URL to extract content from"),
137
+ url: str | None = typer.Argument(None, help="Page URL to extract content from"),
138
+ domain: str | None = typer.Option(
139
+ None, "--domain", help="Bare domain to extract instead of URL (alias for https://DOMAIN, served from cache)."
140
+ ),
138
141
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
139
142
  ) -> None:
140
- """Extract page content from a URL."""
143
+ """Extract page content from a URL or a bare domain."""
141
144
  from discolike_cli.main import get_client
142
145
 
143
- emit(
144
- get_client(ctx).companies.extract(build_request(CompaniesExtractParams, _merge_params(None, url=url))), fmt=fmt
145
- )
146
+ if (url is None) == (domain is None):
147
+ raise typer.BadParameter("Provide exactly one of URL or --domain")
148
+
149
+ request = build_request(CompaniesExtractParams, _merge_params(None, url=url, domain=domain))
150
+ emit(get_client(ctx).companies.extract(request), fmt=fmt)
@@ -25,6 +25,25 @@ WAIT_HELP = "Block until the job finishes, streaming progress to stderr."
25
25
  TIMEOUT_HELP = "Max seconds to wait with --wait."
26
26
  PARAM_HELP = "Extra key=value query parameter forwarded to the SDK (repeatable)."
27
27
  JOBSTART_DATE_HELP = "Job start date filter: min date or 'min,max' range, e.g. 2025-01-01 or 2025-01-01,2025-06-30."
28
+ SUMMARY_HELP = "Filter by profile summary text (semantic search)."
29
+ NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description."
30
+ NAME_HELP = "Filter by contact name (partial match supported)."
31
+ SKILLS_HELP = "Filter by skill (repeatable)."
32
+ FILTER_STATE_HELP = "Filter by company state/region (repeatable)."
33
+ NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)."
34
+ PERSON_STATE_HELP = "Filter by contact state/region (repeatable)."
35
+ PERSONA_ID_HELP = "Filter by persona ID (repeatable)."
36
+ EMAIL_VALIDATED_HELP = "Only contacts with (or without) a validated email address."
37
+ HAS_PHONE_HELP = "Only contacts with (or without) a phone number."
38
+ HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number."
39
+ HAS_LINKEDIN_HELP = "Only contacts with (or without) a LinkedIn profile."
40
+ MIN_CONNECTIONS_HELP = "Minimum LinkedIn connections required."
41
+ INCLUSION_QUERY_ID_HELP = "Include only contacts from companies in this saved query (repeatable)."
42
+ EXCLUSION_QUERY_ID_HELP = "Exclude contacts from companies in this saved query (repeatable)."
43
+ MAX_COMPANIES_HELP = "Maximum number of enriched companies to return; cannot be combined with --max-records."
44
+ RESULTS_BY_COMPANY_HELP = "Maximum contacts per company domain (default 5; 0 removes the cap)."
45
+ INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)."
46
+ CONSENSUS_HELP = "Number of query vectors to combine for consensus search."
28
47
 
29
48
  app = typer.Typer(
30
49
  help="Find contacts: search and count by filters, look up or match individuals, and run bulk/generative discovery jobs."
@@ -42,20 +61,43 @@ def search_command(
42
61
  negate_department: list[str] | None = typer.Option(None, help="Exclude departments (repeatable)."),
43
62
  title: list[str] | None = typer.Option(None, help="Filter by job title (repeatable)."),
44
63
  negate_title: list[str] | None = typer.Option(None, help="Exclude job titles (repeatable)."),
64
+ name: str | None = typer.Option(None, help=NAME_HELP),
65
+ summary: str | None = typer.Option(None, help=SUMMARY_HELP),
66
+ negate_summary: str | None = typer.Option(None, help=NEGATE_SUMMARY_HELP),
67
+ skills: list[str] | None = typer.Option(None, help=SKILLS_HELP),
45
68
  domain: list[str] | None = typer.Option(None, help="Filter by company domain (repeatable)."),
46
69
  person_country: list[str] | None = typer.Option(None, help="Filter by contact country (repeatable)."),
47
70
  negate_person_country: list[str] | None = typer.Option(None, help="Exclude contact countries (repeatable)."),
71
+ person_state: list[str] | None = typer.Option(None, help=PERSON_STATE_HELP),
72
+ persona_id: list[int] | None = typer.Option(None, help=PERSONA_ID_HELP),
48
73
  filter_industry: list[str] | None = typer.Option(None, help="Filter by company industry (repeatable)."),
49
74
  negate_filter_industry: list[str] | None = typer.Option(None, help="Exclude company industries (repeatable)."),
50
75
  filter_country: list[str] | None = typer.Option(None, help="Filter by company country (repeatable)."),
51
76
  negate_filter_country: list[str] | None = typer.Option(None, help="Exclude company countries (repeatable)."),
77
+ filter_state: list[str] | None = typer.Option(None, help=FILTER_STATE_HELP),
78
+ negate_filter_state: list[str] | None = typer.Option(None, help=NEGATE_FILTER_STATE_HELP),
52
79
  employee_range: str | None = typer.Option(None, help="Company employee range, e.g. 50-200."),
53
80
  has_email: bool | None = typer.Option(
54
81
  None, "--has-email/--no-has-email", help="Only contacts with (or without) an email address."
55
82
  ),
83
+ email_validated: bool | None = typer.Option(
84
+ None, "--email-validated/--no-email-validated", help=EMAIL_VALIDATED_HELP
85
+ ),
86
+ has_phone: bool | None = typer.Option(None, "--has-phone/--no-has-phone", help=HAS_PHONE_HELP),
87
+ has_mobile: bool | None = typer.Option(None, "--has-mobile/--no-has-mobile", help=HAS_MOBILE_HELP),
88
+ has_linkedin: bool | None = typer.Option(None, "--has-linkedin/--no-has-linkedin", help=HAS_LINKEDIN_HELP),
89
+ min_connections: int | None = typer.Option(None, help=MIN_CONNECTIONS_HELP),
56
90
  jobstart_date: str | None = typer.Option(None, "--jobstart-date", help=JOBSTART_DATE_HELP),
91
+ inclusion_query_id: list[str] | None = typer.Option(None, help=INCLUSION_QUERY_ID_HELP),
92
+ exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
57
93
  max_records: int | None = typer.Option(None, help="Maximum number of contacts to return."),
94
+ max_companies: int | None = typer.Option(None, help=MAX_COMPANIES_HELP),
58
95
  offset: int | None = typer.Option(None, help="Number of records to skip for pagination."),
96
+ results_by_company: int | None = typer.Option(None, help=RESULTS_BY_COMPANY_HELP),
97
+ include_search_contacts: bool | None = typer.Option(
98
+ None, "--include-search-contacts/--no-include-search-contacts", help=INCLUDE_SEARCH_CONTACTS_HELP
99
+ ),
100
+ consensus: int | None = typer.Option(None, help=CONSENSUS_HELP),
59
101
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
60
102
  param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
61
103
  ) -> None:
@@ -71,18 +113,37 @@ def search_command(
71
113
  negate_department=negate_department,
72
114
  title=title,
73
115
  negate_title=negate_title,
116
+ name=name,
117
+ summary=summary,
118
+ negate_summary=negate_summary,
119
+ skills=skills,
74
120
  domain=domain,
75
121
  person_country=person_country,
76
122
  negate_person_country=negate_person_country,
123
+ person_state=person_state,
124
+ persona_id=persona_id,
77
125
  filter_industry=filter_industry,
78
126
  negate_filter_industry=negate_filter_industry,
79
127
  filter_country=filter_country,
80
128
  negate_filter_country=negate_filter_country,
129
+ filter_state=filter_state,
130
+ negate_filter_state=negate_filter_state,
81
131
  employee_range=employee_range,
82
132
  has_email=has_email,
133
+ email_validated=email_validated,
134
+ has_phone=has_phone,
135
+ has_mobile=has_mobile,
136
+ has_linkedin=has_linkedin,
137
+ min_connections=min_connections,
83
138
  jobstart_date=jobstart_date,
139
+ inclusion_query_id=inclusion_query_id,
140
+ exclusion_query_id=exclusion_query_id,
84
141
  max_records=max_records,
142
+ max_companies=max_companies,
85
143
  offset=offset,
144
+ results_by_company=results_by_company,
145
+ include_search_contacts=include_search_contacts,
146
+ consensus=consensus,
86
147
  )
87
148
  emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt)
88
149
 
@@ -98,18 +159,43 @@ def count_command(
98
159
  negate_department: list[str] | None = typer.Option(None, help="Exclude departments (repeatable)."),
99
160
  title: list[str] | None = typer.Option(None, help="Filter by job title (repeatable)."),
100
161
  negate_title: list[str] | None = typer.Option(None, help="Exclude job titles (repeatable)."),
162
+ name: str | None = typer.Option(None, help=NAME_HELP),
163
+ summary: str | None = typer.Option(None, help=SUMMARY_HELP),
164
+ negate_summary: str | None = typer.Option(None, help=NEGATE_SUMMARY_HELP),
165
+ skills: list[str] | None = typer.Option(None, help=SKILLS_HELP),
101
166
  domain: list[str] | None = typer.Option(None, help="Filter by company domain (repeatable)."),
102
167
  person_country: list[str] | None = typer.Option(None, help="Filter by contact country (repeatable)."),
103
168
  negate_person_country: list[str] | None = typer.Option(None, help="Exclude contact countries (repeatable)."),
169
+ person_state: list[str] | None = typer.Option(None, help=PERSON_STATE_HELP),
170
+ persona_id: list[int] | None = typer.Option(None, help=PERSONA_ID_HELP),
104
171
  filter_industry: list[str] | None = typer.Option(None, help="Filter by company industry (repeatable)."),
105
172
  negate_filter_industry: list[str] | None = typer.Option(None, help="Exclude company industries (repeatable)."),
106
173
  filter_country: list[str] | None = typer.Option(None, help="Filter by company country (repeatable)."),
107
174
  negate_filter_country: list[str] | None = typer.Option(None, help="Exclude company countries (repeatable)."),
175
+ filter_state: list[str] | None = typer.Option(None, help=FILTER_STATE_HELP),
176
+ negate_filter_state: list[str] | None = typer.Option(None, help=NEGATE_FILTER_STATE_HELP),
108
177
  employee_range: str | None = typer.Option(None, help="Company employee range, e.g. 50-200."),
109
178
  has_email: bool | None = typer.Option(
110
179
  None, "--has-email/--no-has-email", help="Only contacts with (or without) an email address."
111
180
  ),
181
+ email_validated: bool | None = typer.Option(
182
+ None, "--email-validated/--no-email-validated", help=EMAIL_VALIDATED_HELP
183
+ ),
184
+ has_phone: bool | None = typer.Option(None, "--has-phone/--no-has-phone", help=HAS_PHONE_HELP),
185
+ has_mobile: bool | None = typer.Option(None, "--has-mobile/--no-has-mobile", help=HAS_MOBILE_HELP),
186
+ has_linkedin: bool | None = typer.Option(None, "--has-linkedin/--no-has-linkedin", help=HAS_LINKEDIN_HELP),
187
+ min_connections: int | None = typer.Option(None, help=MIN_CONNECTIONS_HELP),
112
188
  jobstart_date: str | None = typer.Option(None, "--jobstart-date", help=JOBSTART_DATE_HELP),
189
+ inclusion_query_id: list[str] | None = typer.Option(None, help=INCLUSION_QUERY_ID_HELP),
190
+ exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
191
+ max_records: int | None = typer.Option(None, help="Maximum number of contacts to count (20-10000)."),
192
+ max_companies: int | None = typer.Option(None, help=MAX_COMPANIES_HELP),
193
+ offset: int | None = typer.Option(None, help="Number of records to skip for pagination."),
194
+ results_by_company: int | None = typer.Option(None, help=RESULTS_BY_COMPANY_HELP),
195
+ include_search_contacts: bool | None = typer.Option(
196
+ None, "--include-search-contacts/--no-include-search-contacts", help=INCLUDE_SEARCH_CONTACTS_HELP
197
+ ),
198
+ consensus: int | None = typer.Option(None, help=CONSENSUS_HELP),
113
199
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
114
200
  param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
115
201
  ) -> None:
@@ -125,16 +211,37 @@ def count_command(
125
211
  negate_department=negate_department,
126
212
  title=title,
127
213
  negate_title=negate_title,
214
+ name=name,
215
+ summary=summary,
216
+ negate_summary=negate_summary,
217
+ skills=skills,
128
218
  domain=domain,
129
219
  person_country=person_country,
130
220
  negate_person_country=negate_person_country,
221
+ person_state=person_state,
222
+ persona_id=persona_id,
131
223
  filter_industry=filter_industry,
132
224
  negate_filter_industry=negate_filter_industry,
133
225
  filter_country=filter_country,
134
226
  negate_filter_country=negate_filter_country,
227
+ filter_state=filter_state,
228
+ negate_filter_state=negate_filter_state,
135
229
  employee_range=employee_range,
136
230
  has_email=has_email,
231
+ email_validated=email_validated,
232
+ has_phone=has_phone,
233
+ has_mobile=has_mobile,
234
+ has_linkedin=has_linkedin,
235
+ min_connections=min_connections,
137
236
  jobstart_date=jobstart_date,
237
+ inclusion_query_id=inclusion_query_id,
238
+ exclusion_query_id=exclusion_query_id,
239
+ max_records=max_records,
240
+ max_companies=max_companies,
241
+ offset=offset,
242
+ results_by_company=results_by_company,
243
+ include_search_contacts=include_search_contacts,
244
+ consensus=consensus,
138
245
  )
139
246
  emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt)
140
247
 
@@ -220,19 +327,37 @@ def discover_command(
220
327
  negate_department: list[str] | None = typer.Option(None, help="Exclude departments (repeatable)."),
221
328
  title: list[str] | None = typer.Option(None, help="Filter by job title (repeatable)."),
222
329
  negate_title: list[str] | None = typer.Option(None, help="Exclude job titles (repeatable)."),
330
+ name: str | None = typer.Option(None, help=NAME_HELP),
331
+ summary: str | None = typer.Option(None, help=SUMMARY_HELP),
332
+ negate_summary: str | None = typer.Option(None, help=NEGATE_SUMMARY_HELP),
333
+ skills: list[str] | None = typer.Option(None, help=SKILLS_HELP),
223
334
  domain: list[str] | None = typer.Option(None, help="Filter by company domain (repeatable)."),
224
335
  person_country: list[str] | None = typer.Option(None, help="Filter by contact country (repeatable)."),
225
336
  negate_person_country: list[str] | None = typer.Option(None, help="Exclude contact countries (repeatable)."),
337
+ person_state: list[str] | None = typer.Option(None, help=PERSON_STATE_HELP),
338
+ persona_id: list[int] | None = typer.Option(None, help=PERSONA_ID_HELP),
226
339
  filter_industry: list[str] | None = typer.Option(None, help="Filter by company industry (repeatable)."),
227
340
  negate_filter_industry: list[str] | None = typer.Option(None, help="Exclude company industries (repeatable)."),
228
341
  filter_country: list[str] | None = typer.Option(None, help="Filter by company country (repeatable)."),
229
342
  negate_filter_country: list[str] | None = typer.Option(None, help="Exclude company countries (repeatable)."),
343
+ filter_state: list[str] | None = typer.Option(None, help=FILTER_STATE_HELP),
344
+ negate_filter_state: list[str] | None = typer.Option(None, help=NEGATE_FILTER_STATE_HELP),
230
345
  employee_range: str | None = typer.Option(None, help="Company employee range, e.g. 50-200."),
231
346
  has_email: bool | None = typer.Option(
232
347
  None, "--has-email/--no-has-email", help="Only contacts with (or without) an email address."
233
348
  ),
349
+ email_validated: bool | None = typer.Option(
350
+ None, "--email-validated/--no-email-validated", help=EMAIL_VALIDATED_HELP
351
+ ),
352
+ has_phone: bool | None = typer.Option(None, "--has-phone/--no-has-phone", help=HAS_PHONE_HELP),
353
+ has_mobile: bool | None = typer.Option(None, "--has-mobile/--no-has-mobile", help=HAS_MOBILE_HELP),
354
+ has_linkedin: bool | None = typer.Option(None, "--has-linkedin/--no-has-linkedin", help=HAS_LINKEDIN_HELP),
355
+ min_connections: int | None = typer.Option(None, help=MIN_CONNECTIONS_HELP),
234
356
  jobstart_date: str | None = typer.Option(None, "--jobstart-date", help=JOBSTART_DATE_HELP),
357
+ inclusion_query_id: list[str] | None = typer.Option(None, help=INCLUSION_QUERY_ID_HELP),
358
+ exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
235
359
  max_records: int | None = typer.Option(None, help="Maximum number of contacts to return."),
360
+ max_companies: int | None = typer.Option(None, help=MAX_COMPANIES_HELP),
236
361
  offset: int | None = typer.Option(None, help="Number of records to skip for pagination."),
237
362
  results_by_company: int | None = typer.Option(
238
363
  None, "--results-by-company", help="Maximum contacts returned per company."
@@ -258,17 +383,33 @@ def discover_command(
258
383
  negate_department=negate_department,
259
384
  title=title,
260
385
  negate_title=negate_title,
386
+ name=name,
387
+ summary=summary,
388
+ negate_summary=negate_summary,
389
+ skills=skills,
261
390
  domain=domain,
262
391
  person_country=person_country,
263
392
  negate_person_country=negate_person_country,
393
+ person_state=person_state,
394
+ persona_id=persona_id,
264
395
  filter_industry=filter_industry,
265
396
  negate_filter_industry=negate_filter_industry,
266
397
  filter_country=filter_country,
267
398
  negate_filter_country=negate_filter_country,
399
+ filter_state=filter_state,
400
+ negate_filter_state=negate_filter_state,
268
401
  employee_range=employee_range,
269
402
  has_email=has_email,
403
+ email_validated=email_validated,
404
+ has_phone=has_phone,
405
+ has_mobile=has_mobile,
406
+ has_linkedin=has_linkedin,
407
+ min_connections=min_connections,
270
408
  jobstart_date=jobstart_date,
409
+ inclusion_query_id=inclusion_query_id,
410
+ exclusion_query_id=exclusion_query_id,
271
411
  max_records=max_records,
412
+ max_companies=max_companies,
272
413
  offset=offset,
273
414
  results_by_company=results_by_company,
274
415
  include_search_contacts=include_search_contacts,
@@ -283,6 +424,12 @@ def generate_command(
283
424
  ctx: typer.Context,
284
425
  icp_text: str = typer.Option(..., "--icp-text", help="ICP description used to generate contacts."),
285
426
  domain: list[str] = typer.Option(..., "--domain", help="Target company domain (repeatable)."),
427
+ full_domain: list[str] | None = typer.Option(
428
+ None, "--full-domain", help="Domain to send as full_domains to the generation job (repeatable)."
429
+ ),
430
+ partial_domain: list[str] | None = typer.Option(
431
+ None, "--partial-domain", help="Domain to send as partial_domains to the generation job (repeatable)."
432
+ ),
286
433
  context_mode: str | None = typer.Option(None, "--context-mode", help="Context mode for generation."),
287
434
  integration_id: str | None = typer.Option(None, "--integration-id", help="Integration ID to use for generation."),
288
435
  search_provider_id: str | None = typer.Option(
@@ -310,6 +457,8 @@ def generate_command(
310
457
  None,
311
458
  icp_text=icp_text,
312
459
  domains=domain,
460
+ full_domains=full_domain,
461
+ partial_domains=partial_domain,
313
462
  context_mode=context_mode,
314
463
  integration_id=integration_id,
315
464
  search_provider_id=search_provider_id,
@@ -15,6 +15,19 @@ LIST_VALUE_SEPARATOR = ","
15
15
 
16
16
  FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
17
17
  PARAM_HELP = "Extra API parameter as KEY=VALUE (comma-separates into a list); see docs.discolike.com"
18
+ SUBDOMAIN_HELP = "Limit results to this subdomain, up to 20, each at least 3 characters (repeatable)."
19
+ NEGATE_SUBDOMAIN_HELP = "Exclude this subdomain, up to 20, each at least 3 characters (repeatable)."
20
+ START_DATE_HELP = "Minimum company start date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD)."
21
+ REDIRECT_HELP = "Include or exclude domains that redirect to another domain."
22
+ SOCIAL_HELP = (
23
+ "Filter by social platform presence (repeatable): facebook, instagram, linkedin, pinterest, threads, tiktok, "
24
+ "twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing."
25
+ )
26
+ NEGATE_SOCIAL_HELP = "Exclude companies with this social profile (repeatable); same values as --social."
27
+ LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)."
28
+ NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language."
29
+ EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)."
30
+ COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)."
18
31
 
19
32
 
20
33
  def _parse_param(raw: str) -> tuple[str, str | list[str]]:
@@ -45,8 +58,15 @@ def discover_command(
45
58
  negate_country: list[str] | None = typer.Option(None, help="Negate the --country filter (repeatable)."),
46
59
  state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."),
47
60
  negate_state: list[str] | None = typer.Option(None, help="Negate the --state filter (repeatable)."),
61
+ subdomain: list[str] | None = typer.Option(None, help=SUBDOMAIN_HELP),
62
+ negate_subdomain: list[str] | None = typer.Option(None, help=NEGATE_SUBDOMAIN_HELP),
63
+ language: list[str] | None = typer.Option(None, help=LANGUAGE_HELP),
64
+ negate_language: list[str] | None = typer.Option(None, help=NEGATE_LANGUAGE_HELP),
65
+ social: list[str] | None = typer.Option(None, help=SOCIAL_HELP),
66
+ negate_social: list[str] | None = typer.Option(None, help=NEGATE_SOCIAL_HELP),
48
67
  employee_range: str | None = typer.Option(None, help="Employee count range filter."),
49
68
  revenue_range: str | None = typer.Option(None, help="Revenue range filter."),
69
+ start_date: str | None = typer.Option(None, help=START_DATE_HELP),
50
70
  business_model: list[str] | None = typer.Option(None, help="Business model filter (repeatable)."),
51
71
  negate_business_model: list[str] | None = typer.Option(
52
72
  None, help="Negate the --business-model filter (repeatable)."
@@ -55,7 +75,38 @@ def discover_command(
55
75
  negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."),
56
76
  min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."),
57
77
  max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."),
78
+ min_similarity: int | None = typer.Option(None, help="Minimum similarity score to include (0-99)."),
79
+ variance: str | None = typer.Option(
80
+ None, help="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED."
81
+ ),
82
+ consensus: int | None = typer.Option(
83
+ None, help="Number of top results for the consensus search vector (1-20); higher reduces specificity."
84
+ ),
85
+ redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
86
+ exclude_leadgen: bool | None = typer.Option(
87
+ None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP
88
+ ),
89
+ retrieval: bool | None = typer.Option(
90
+ None, "--retrieval/--no-retrieval", help="Enable page data retrieval using the Extract API."
91
+ ),
92
+ enhanced: bool | None = typer.Option(
93
+ None, "--enhanced/--no-enhanced", help="Enable AI-powered result enhancement for improved relevance."
94
+ ),
95
+ include_search_domains: bool | None = typer.Option(
96
+ None,
97
+ "--include-search-domains/--no-include-search-domains",
98
+ help="Include the input domains in results (excluded by default).",
99
+ ),
100
+ auto_icp_text: bool | None = typer.Option(
101
+ None, "--auto-icp-text/--no-auto-icp-text", help="Auto-generate ICP text from the provided domain(s)."
102
+ ),
103
+ auto_phrase_match: bool | None = typer.Option(
104
+ None, "--auto-phrase-match/--no-auto-phrase-match", help="Auto-generate phrase matches from ICP text."
105
+ ),
58
106
  exclude_domain: list[str] | None = typer.Option(None, help="Domain to exclude from results (repeatable)."),
107
+ inclusion_query_id: list[str] | None = typer.Option(
108
+ None, help="Saved query ID whose domains are included (repeatable); requires the STARTER plan."
109
+ ),
59
110
  exclusion_query_id: list[str] | None = typer.Option(
60
111
  None, help="Saved query ID whose results are excluded (repeatable)."
61
112
  ),
@@ -81,15 +132,33 @@ def discover_command(
81
132
  negate_country=negate_country,
82
133
  state=state,
83
134
  negate_state=negate_state,
135
+ subdomain=subdomain,
136
+ negate_subdomain=negate_subdomain,
137
+ language=language,
138
+ negate_language=negate_language,
139
+ social=social,
140
+ negate_social=negate_social,
84
141
  employee_range=employee_range,
85
142
  revenue_range=revenue_range,
143
+ start_date=start_date,
86
144
  business_model=business_model,
87
145
  negate_business_model=negate_business_model,
88
146
  tech_stack=tech_stack,
89
147
  negate_tech_stack=negate_tech_stack,
90
148
  min_digital_footprint=min_digital_footprint,
91
149
  max_digital_footprint=max_digital_footprint,
150
+ min_similarity=min_similarity,
151
+ variance=variance,
152
+ consensus=consensus,
153
+ redirect=redirect,
154
+ exclude_leadgen=exclude_leadgen,
155
+ retrieval=retrieval,
156
+ enhanced=enhanced,
157
+ include_search_domains=include_search_domains,
158
+ auto_icp_text=auto_icp_text,
159
+ auto_phrase_match=auto_phrase_match,
92
160
  exclude_domain=exclude_domain,
161
+ inclusion_query_id=inclusion_query_id,
93
162
  exclusion_query_id=exclusion_query_id,
94
163
  max_records=max_records,
95
164
  offset=offset,
@@ -109,8 +178,15 @@ def count_command(
109
178
  negate_country: list[str] | None = typer.Option(None, help="Negate the --country filter (repeatable)."),
110
179
  state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."),
111
180
  negate_state: list[str] | None = typer.Option(None, help="Negate the --state filter (repeatable)."),
181
+ subdomain: list[str] | None = typer.Option(None, help=SUBDOMAIN_HELP),
182
+ negate_subdomain: list[str] | None = typer.Option(None, help=NEGATE_SUBDOMAIN_HELP),
183
+ language: list[str] | None = typer.Option(None, help=LANGUAGE_HELP),
184
+ negate_language: list[str] | None = typer.Option(None, help=NEGATE_LANGUAGE_HELP),
185
+ social: list[str] | None = typer.Option(None, help=SOCIAL_HELP),
186
+ negate_social: list[str] | None = typer.Option(None, help=NEGATE_SOCIAL_HELP),
112
187
  employee_range: str | None = typer.Option(None, help="Employee count range filter."),
113
188
  revenue_range: str | None = typer.Option(None, help="Revenue range filter."),
189
+ start_date: str | None = typer.Option(None, help=START_DATE_HELP),
114
190
  business_model: list[str] | None = typer.Option(None, help="Business model filter (repeatable)."),
115
191
  negate_business_model: list[str] | None = typer.Option(
116
192
  None, help="Negate the --business-model filter (repeatable)."
@@ -119,6 +195,10 @@ def count_command(
119
195
  negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."),
120
196
  min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."),
121
197
  max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."),
198
+ redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
199
+ exclude_leadgen: bool | None = typer.Option(
200
+ None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP
201
+ ),
122
202
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
123
203
  param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
124
204
  ) -> None:
@@ -137,14 +217,23 @@ def count_command(
137
217
  negate_country=negate_country,
138
218
  state=state,
139
219
  negate_state=negate_state,
220
+ subdomain=subdomain,
221
+ negate_subdomain=negate_subdomain,
222
+ language=language,
223
+ negate_language=negate_language,
224
+ social=social,
225
+ negate_social=negate_social,
140
226
  employee_range=employee_range,
141
227
  revenue_range=revenue_range,
228
+ start_date=start_date,
142
229
  business_model=business_model,
143
230
  negate_business_model=negate_business_model,
144
231
  tech_stack=tech_stack,
145
232
  negate_tech_stack=negate_tech_stack,
146
233
  min_digital_footprint=min_digital_footprint,
147
234
  max_digital_footprint=max_digital_footprint,
235
+ redirect=redirect,
236
+ exclude_leadgen=exclude_leadgen,
148
237
  ),
149
238
  )
150
239
  emit(get_client(ctx).count(request), fmt=fmt)
@@ -19,6 +19,7 @@ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
19
19
  FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
20
20
  WAIT_HELP = "Block until the job finishes, streaming progress to stderr."
21
21
  TIMEOUT_HELP = "Max seconds to wait with --wait."
22
+ QUERY_ID_HELP = "Saved query ID whose domains are included alongside the file/--domain ones (repeatable)."
22
23
 
23
24
 
24
25
  @handle_errors
@@ -76,6 +77,7 @@ def append_command(
76
77
  file: pathlib.Path = typer.Argument(..., help="CSV of domains to enrich."),
77
78
  dataset: list[str] | None = typer.Option(None, "--dataset", help="Dataset to append (repeatable)."),
78
79
  domain_column: str | None = typer.Option(None, "--domain-column", help="Column in the CSV that holds domains."),
80
+ query_id: list[str] | None = typer.Option(None, "--query-id", help=QUERY_ID_HELP),
79
81
  csv: bool | None = typer.Option(
80
82
  None, "--csv/--no-csv", help="Request the enriched rows as CSV (written via --output)."
81
83
  ),
@@ -85,7 +87,9 @@ def append_command(
85
87
  """Enrich a CSV of domains with DiscoLike datasets."""
86
88
  from discolike_cli.main import get_client
87
89
 
88
- request = build_request(AppendParams, _merge_params(None, dataset=dataset, domain_column=domain_column, csv=csv))
90
+ request = build_request(
91
+ AppendParams, _merge_params(None, dataset=dataset, domain_column=domain_column, query_id=query_id, csv=csv)
92
+ )
89
93
  result = get_client(ctx).append(request, file=file)
90
94
  if isinstance(result, bytes):
91
95
  if output is None:
@@ -102,6 +106,7 @@ def segment_command(
102
106
  domain: list[str] | None = typer.Option(None, "--domain", help="Domain to segment (repeatable)."),
103
107
  file: pathlib.Path | None = typer.Option(None, "--file", help="CSV of domains to segment (instead of --domain)."),
104
108
  domain_column: str | None = typer.Option(None, "--domain-column", help="Column in --file that holds domains."),
109
+ query_id: list[str] | None = typer.Option(None, "--query-id", help=QUERY_ID_HELP),
105
110
  max_segments: int | None = typer.Option(None, "--max-segments", help="Maximum number of segments to produce."),
106
111
  wait: bool = typer.Option(False, "--wait", help=WAIT_HELP),
107
112
  timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
@@ -116,11 +121,14 @@ def segment_command(
116
121
  client = get_client(ctx)
117
122
  if file is not None:
118
123
  request = build_request(
119
- SegmentFileParams, _merge_params(None, domain_column=domain_column, max_segments=max_segments)
124
+ SegmentFileParams,
125
+ _merge_params(None, domain_column=domain_column, query_id=query_id, max_segments=max_segments),
120
126
  )
121
127
  job = client.segment_file(request, file=file)
122
128
  else:
123
129
  assert domain is not None
124
- request = build_request(SegmentParams, _merge_params(None, domains=",".join(domain), max_segments=max_segments))
130
+ request = build_request(
131
+ SegmentParams, _merge_params(None, domains=",".join(domain), query_id=query_id, max_segments=max_segments)
132
+ )
125
133
  job = client.segment(request)
126
134
  run_job(job, wait=wait, timeout=timeout, fmt=fmt)
@@ -18,6 +18,7 @@ DEFAULT_NAME_COLUMN = "name"
18
18
  FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
19
19
  WAIT_HELP = "Block until the job finishes, streaming progress to stderr."
20
20
  TIMEOUT_HELP = "Max seconds to wait with --wait."
21
+ MIN_MATCH_CONFIDENCE_HELP = "Minimum match_confidence (inclusive, 50-100) a match must have to be returned."
21
22
 
22
23
 
23
24
  @handle_errors
@@ -29,6 +30,7 @@ def match_command(
29
30
  state: str | None = typer.Option(None, help="State or region to disambiguate the match."),
30
31
  country: str | None = typer.Option(None, help="Country to disambiguate the match."),
31
32
  zip_code: str | None = typer.Option(None, help="ZIP or postal code to disambiguate the match."),
33
+ min_match_confidence: int | None = typer.Option(None, help=MIN_MATCH_CONFIDENCE_HELP),
32
34
  strict: bool | None = typer.Option(None, "--strict/--no-strict", help="Toggle strict matching."),
33
35
  local_mode: bool | None = typer.Option(None, "--local-mode/--no-local-mode", help="Toggle local matching mode."),
34
36
  file: pathlib.Path | None = typer.Option(
@@ -37,6 +39,15 @@ def match_command(
37
39
  name_column: str = typer.Option(
38
40
  DEFAULT_NAME_COLUMN, "--name-column", help="Column in --file that holds company names."
39
41
  ),
42
+ phone_column: str | None = typer.Option(None, "--phone-column", help="Column in --file that holds phone numbers."),
43
+ city_column: str | None = typer.Option(None, "--city-column", help="Column in --file that holds cities."),
44
+ state_column: str | None = typer.Option(None, "--state-column", help="Column in --file that holds states."),
45
+ country_column: str | None = typer.Option(
46
+ None, "--country-column", help="Column in --file that holds country codes."
47
+ ),
48
+ zip_code_column: str | None = typer.Option(
49
+ None, "--zip-code-column", help="Column in --file that holds zip codes."
50
+ ),
40
51
  wait: bool = typer.Option(False, "--wait", help=WAIT_HELP),
41
52
  timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
42
53
  fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
@@ -59,6 +70,7 @@ def match_command(
59
70
  state=state,
60
71
  country=country,
61
72
  zip_code=zip_code,
73
+ min_match_confidence=min_match_confidence,
62
74
  strict=strict,
63
75
  local_mode=local_mode,
64
76
  ),
@@ -68,6 +80,18 @@ def match_command(
68
80
 
69
81
  assert file is not None
70
82
  request = build_request(
71
- MatchBulkParams, _merge_params(None, name_column=name_column, strict=strict, local_mode=local_mode)
83
+ MatchBulkParams,
84
+ _merge_params(
85
+ None,
86
+ name_column=name_column,
87
+ phone_column=phone_column,
88
+ city_column=city_column,
89
+ state_column=state_column,
90
+ country_column=country_column,
91
+ zip_code_column=zip_code_column,
92
+ min_match_confidence=min_match_confidence,
93
+ strict=strict,
94
+ local_mode=local_mode,
95
+ ),
72
96
  )
73
97
  run_job(client.match.bulk(request, file=file), wait=wait, timeout=timeout, fmt=fmt)
@@ -144,3 +144,27 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[
144
144
  assert request.url.path == "/v1/extract"
145
145
  assert request.url.params.get("url") == "https://acme.com/about"
146
146
  assert json.loads(result.stdout) == {"text": "hello", "language": "en"}
147
+
148
+
149
+ def test_extract_domain_option_replaces_url(install_build_client: Callable[[Handler], None]) -> None:
150
+ captured: dict[str, httpx2.Request] = {}
151
+
152
+ def handler(request: httpx2.Request) -> httpx2.Response:
153
+ captured["request"] = request
154
+ return httpx2.Response(200, json={"text": "hello", "language": "en"})
155
+
156
+ install_build_client(handler)
157
+ result = runner.invoke(app, ["extract", "--domain", "acme.com"])
158
+ assert result.exit_code == 0, result.output
159
+ params = captured["request"].url.params
160
+ assert params.get("domain") == "acme.com"
161
+ assert "url" not in params
162
+
163
+
164
+ def test_extract_requires_exactly_one_of_url_or_domain(install_build_client: Callable[[Handler], None]) -> None:
165
+ def handler(request: httpx2.Request) -> httpx2.Response:
166
+ return httpx2.Response(200, json={})
167
+
168
+ install_build_client(handler)
169
+ assert runner.invoke(app, ["extract"]).exit_code == 2
170
+ assert runner.invoke(app, ["extract", "https://acme.com", "--domain", "acme.com"]).exit_code == 2
@@ -76,6 +76,15 @@ def test_contacts_search_sends_options_and_param_escape_hatch(install_build_clie
76
76
  ]
77
77
 
78
78
 
79
+ def test_contacts_search_icp_text_is_removed(install_build_client: Callable[[Handler], None]) -> None:
80
+ def handler(request: httpx2.Request) -> httpx2.Response:
81
+ return httpx2.Response(200, json=[])
82
+
83
+ install_build_client(handler)
84
+ result = runner.invoke(app, ["contacts", "search", "--icp-text", "X"])
85
+ assert result.exit_code == 2
86
+
87
+
79
88
  def test_contacts_search_forwards_negate_options(install_build_client: Callable[[Handler], None]) -> None:
80
89
  captured: dict[str, httpx2.QueryParams] = {}
81
90
 
@@ -113,15 +122,6 @@ def test_contacts_search_forwards_negate_options(install_build_client: Callable[
113
122
  assert params.get_list("negate_filter_country") == ["RU"]
114
123
 
115
124
 
116
- def test_contacts_search_icp_text_is_removed(install_build_client: Callable[[Handler], None]) -> None:
117
- def handler(request: httpx2.Request) -> httpx2.Response:
118
- return httpx2.Response(200, json=[])
119
-
120
- install_build_client(handler)
121
- result = runner.invoke(app, ["contacts", "search", "--icp-text", "X"])
122
- assert result.exit_code == 2
123
-
124
-
125
125
  def test_contacts_search_unknown_param_passes_through(install_build_client: Callable[[Handler], None]) -> None:
126
126
  captured: dict[str, httpx2.QueryParams] = {}
127
127
 
@@ -346,3 +346,156 @@ def test_contacts_generate_without_wait_prints_task_hint(install_build_client: C
346
346
  payload = json.loads(result.stdout)
347
347
  assert payload["task_id"] == "dg-1"
348
348
  assert payload["task_family"] == "discogen"
349
+
350
+
351
+ NEW_CONTACT_FILTER_ARGS = [
352
+ "--name",
353
+ "Jane",
354
+ "--summary",
355
+ "scaled a sales org",
356
+ "--negate-summary",
357
+ "intern",
358
+ "--skills",
359
+ "SQL",
360
+ "--person-state",
361
+ "CA",
362
+ "--persona-id",
363
+ "7",
364
+ "--filter-state",
365
+ "TX",
366
+ "--negate-filter-state",
367
+ "NY",
368
+ "--email-validated",
369
+ "--has-phone",
370
+ "--no-has-mobile",
371
+ "--has-linkedin",
372
+ "--min-connections",
373
+ "500",
374
+ "--inclusion-query-id",
375
+ "q1",
376
+ "--exclusion-query-id",
377
+ "q2",
378
+ "--max-companies",
379
+ "10",
380
+ "--results-by-company",
381
+ "2",
382
+ "--include-search-contacts",
383
+ "--consensus",
384
+ "3",
385
+ ]
386
+
387
+
388
+ def _assert_new_contact_filters(params: httpx2.QueryParams) -> None:
389
+ assert params.get("name") == "Jane"
390
+ assert params.get("summary") == "scaled a sales org"
391
+ assert params.get("negate_summary") == "intern"
392
+ assert params.get_list("skills") == ["SQL"]
393
+ assert params.get_list("person_state") == ["CA"]
394
+ assert params.get_list("persona_id") == ["7"]
395
+ assert params.get_list("filter_state") == ["TX"]
396
+ assert params.get_list("negate_filter_state") == ["NY"]
397
+ assert params.get("email_validated") == "true"
398
+ assert params.get("has_phone") == "true"
399
+ assert params.get("has_mobile") == "false"
400
+ assert params.get("has_linkedin") == "true"
401
+ assert params.get("min_connections") == "500"
402
+ assert params.get_list("inclusion_query_id") == ["q1"]
403
+ assert params.get_list("exclusion_query_id") == ["q2"]
404
+ assert params.get("max_companies") == "10"
405
+ assert params.get("results_by_company") == "2"
406
+ assert params.get("include_search_contacts") == "true"
407
+ assert params.get("consensus") == "3"
408
+
409
+
410
+ def test_contacts_search_forwards_every_new_flag(install_build_client: Callable[[Handler], None]) -> None:
411
+ captured: dict[str, httpx2.QueryParams] = {}
412
+
413
+ def handler(request: httpx2.Request) -> httpx2.Response:
414
+ captured["params"] = request.url.params
415
+ return httpx2.Response(200, json=[])
416
+
417
+ install_build_client(handler)
418
+ result = runner.invoke(app, ["contacts", "search", *NEW_CONTACT_FILTER_ARGS])
419
+ assert result.exit_code == 0, result.output
420
+ _assert_new_contact_filters(captured["params"])
421
+
422
+
423
+ def test_contacts_count_forwards_every_new_flag(install_build_client: Callable[[Handler], None]) -> None:
424
+ captured: dict[str, httpx2.QueryParams] = {}
425
+
426
+ def handler(request: httpx2.Request) -> httpx2.Response:
427
+ captured["params"] = request.url.params
428
+ return httpx2.Response(200, json={"count": 1})
429
+
430
+ install_build_client(handler)
431
+ result = runner.invoke(app, ["contacts", "count", *NEW_CONTACT_FILTER_ARGS, "--max-records", "20", "--offset", "5"])
432
+ assert result.exit_code == 0, result.output
433
+ params = captured["params"]
434
+ _assert_new_contact_filters(params)
435
+ assert params.get("max_records") == "20"
436
+ assert params.get("offset") == "5"
437
+
438
+
439
+ def test_contacts_discover_forwards_every_new_flag(install_build_client: Callable[[Handler], None]) -> None:
440
+ captured: dict[str, object] = {}
441
+
442
+ def handler(request: httpx2.Request) -> httpx2.Response:
443
+ captured["body"] = json.loads(request.content)
444
+ return httpx2.Response(200, json={"results": {}, "total_contacts": 0})
445
+
446
+ install_build_client(handler)
447
+ result = runner.invoke(app, ["contacts", "discover", *NEW_CONTACT_FILTER_ARGS])
448
+ assert result.exit_code == 0, result.output
449
+ assert captured["body"] == {
450
+ "name": "Jane",
451
+ "summary": "scaled a sales org",
452
+ "negate_summary": "intern",
453
+ "skills": ["SQL"],
454
+ "person_state": ["CA"],
455
+ "persona_id": [7],
456
+ "filter_state": ["TX"],
457
+ "negate_filter_state": ["NY"],
458
+ "email_validated": True,
459
+ "has_phone": True,
460
+ "has_mobile": False,
461
+ "has_linkedin": True,
462
+ "min_connections": 500,
463
+ "inclusion_query_id": ["q1"],
464
+ "exclusion_query_id": ["q2"],
465
+ "max_companies": 10,
466
+ "results_by_company": 2,
467
+ "include_search_contacts": True,
468
+ "consensus": 3,
469
+ }
470
+
471
+
472
+ def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None:
473
+ captured: dict[str, object] = {}
474
+
475
+ def handler(request: httpx2.Request) -> httpx2.Response:
476
+ captured["body"] = json.loads(request.content)
477
+ return httpx2.Response(200, json={"task_id": "dg-9"})
478
+
479
+ install_build_client(handler)
480
+ result = runner.invoke(
481
+ app,
482
+ [
483
+ "contacts",
484
+ "generate",
485
+ "--icp-text",
486
+ "VPs of Marketing",
487
+ "--domain",
488
+ "acme.com",
489
+ "--full-domain",
490
+ "done.com",
491
+ "--partial-domain",
492
+ "half.com",
493
+ ],
494
+ )
495
+ assert result.exit_code == 0, result.output
496
+ assert captured["body"] == {
497
+ "icp_text": "VPs of Marketing",
498
+ "domains": ["acme.com"],
499
+ "full_domains": ["done.com"],
500
+ "partial_domains": ["half.com"],
501
+ }
@@ -118,6 +118,120 @@ def test_discover_icp_text_option_removed(install_build_client: Callable[[Handle
118
118
  assert result.exit_code == 2
119
119
 
120
120
 
121
+ def test_discover_forwards_every_new_flag(install_build_client: Callable[[Handler], None]) -> None:
122
+ captured: dict[str, httpx2.QueryParams] = {}
123
+
124
+ def handler(request: httpx2.Request) -> httpx2.Response:
125
+ captured["params"] = request.url.params
126
+ return _discover_ok(request)
127
+
128
+ install_build_client(handler)
129
+ result = runner.invoke(
130
+ app,
131
+ [
132
+ "discover",
133
+ "--subdomain",
134
+ "shop",
135
+ "--negate-subdomain",
136
+ "blog",
137
+ "--language",
138
+ "en",
139
+ "--negate-language",
140
+ "de",
141
+ "--social",
142
+ "linkedin",
143
+ "--negate-social",
144
+ "tiktok",
145
+ "--start-date",
146
+ "2020-01-01",
147
+ "--min-similarity",
148
+ "40",
149
+ "--variance",
150
+ "MEDIUM",
151
+ "--consensus",
152
+ "3",
153
+ "--redirect",
154
+ "--no-exclude-leadgen",
155
+ "--retrieval",
156
+ "--enhanced",
157
+ "--include-search-domains",
158
+ "--auto-icp-text",
159
+ "--auto-phrase-match",
160
+ "--inclusion-query-id",
161
+ "q1",
162
+ ],
163
+ )
164
+ assert result.exit_code == 0, result.output
165
+ params = captured["params"]
166
+ assert params.get_list("subdomain") == ["shop"]
167
+ assert params.get_list("negate_subdomain") == ["blog"]
168
+ assert params.get_list("language") == ["en"]
169
+ assert params.get_list("negate_language") == ["de"]
170
+ assert params.get_list("social") == ["linkedin"]
171
+ assert params.get_list("negate_social") == ["tiktok"]
172
+ assert params.get("start_date") == "2020-01-01"
173
+ assert params.get("min_similarity") == "40"
174
+ assert params.get("variance") == "MEDIUM"
175
+ assert params.get("consensus") == "3"
176
+ assert params.get("redirect") == "true"
177
+ assert params.get("exclude_leadgen") == "false"
178
+ assert params.get("retrieval") == "true"
179
+ assert params.get("enhanced") == "true"
180
+ assert params.get("include_search_domains") == "true"
181
+ assert params.get("auto_icp_text") == "true"
182
+ assert params.get("auto_phrase_match") == "true"
183
+ assert params.get_list("inclusion_query_id") == ["q1"]
184
+
185
+
186
+ def test_discover_rejects_invalid_variance(install_build_client: Callable[[Handler], None]) -> None:
187
+ install_build_client(_discover_ok)
188
+ result = runner.invoke(app, ["discover", "--variance", "BOGUS"])
189
+ assert result.exit_code == 2
190
+
191
+
192
+ def test_count_forwards_every_new_flag(install_build_client: Callable[[Handler], None]) -> None:
193
+ captured: dict[str, httpx2.QueryParams] = {}
194
+
195
+ def handler(request: httpx2.Request) -> httpx2.Response:
196
+ captured["params"] = request.url.params
197
+ return _count_ok(request)
198
+
199
+ install_build_client(handler)
200
+ result = runner.invoke(
201
+ app,
202
+ [
203
+ "count",
204
+ "--subdomain",
205
+ "shop",
206
+ "--negate-subdomain",
207
+ "blog",
208
+ "--language",
209
+ "en",
210
+ "--negate-language",
211
+ "de",
212
+ "--social",
213
+ "linkedin",
214
+ "--negate-social",
215
+ "tiktok",
216
+ "--start-date",
217
+ "2020-01-01,2021-01-01",
218
+ "--no-redirect",
219
+ "--exclude-leadgen",
220
+ ],
221
+ )
222
+ assert result.exit_code == 0, result.output
223
+ params = captured["params"]
224
+ assert params.get_list("subdomain") == ["shop"]
225
+ assert params.get_list("negate_subdomain") == ["blog"]
226
+ assert params.get_list("language") == ["en"]
227
+ assert params.get_list("negate_language") == ["de"]
228
+ assert params.get_list("social") == ["linkedin"]
229
+ assert params.get_list("negate_social") == ["tiktok"]
230
+ assert params.get("start_date") == "2020-01-01,2021-01-01"
231
+ assert params.get("redirect") == "false"
232
+ assert params.get("exclude_leadgen") == "true"
233
+
234
+
121
235
  def test_count_sends_shared_filter_subset(install_build_client: Callable[[Handler], None]) -> None:
122
236
  captured: dict[str, httpx2.QueryParams] = {}
123
237
 
@@ -234,3 +234,40 @@ def test_segment_neither_domain_nor_file_exits_2(install_build_client: Callable[
234
234
  install_build_client(handler)
235
235
  result = runner.invoke(app, ["segment"])
236
236
  assert result.exit_code == 2
237
+
238
+
239
+ def test_append_forwards_query_id(tmp_path, install_build_client: Callable[[Handler], None]) -> None:
240
+ domains_file = tmp_path / "domains.csv"
241
+ domains_file.write_text("domain\nacme.com\n")
242
+ captured: dict[str, httpx2.Request] = {}
243
+
244
+ def handler(request: httpx2.Request) -> httpx2.Response:
245
+ captured["request"] = request
246
+ return httpx2.Response(200, json=[{"domain": "acme.com"}])
247
+
248
+ install_build_client(handler)
249
+ result = runner.invoke(
250
+ app, ["append", str(domains_file), "--dataset", "bizdata", "--query-id", "q1", "--query-id", "q2"]
251
+ )
252
+ assert result.exit_code == 0, result.output
253
+ assert captured["request"].url.params.get_list("query_id") == ["q1", "q2"]
254
+
255
+
256
+ def test_segment_forwards_query_id_for_domains_and_file(
257
+ tmp_path, install_build_client: Callable[[Handler], None]
258
+ ) -> None:
259
+ domains_file = tmp_path / "domains.csv"
260
+ domains_file.write_text("domain\nacme.com\n")
261
+ captured: list[httpx2.Request] = []
262
+
263
+ def handler(request: httpx2.Request) -> httpx2.Response:
264
+ captured.append(request)
265
+ return httpx2.Response(200, json={"task_id": "sg-1"})
266
+
267
+ install_build_client(handler)
268
+ result = runner.invoke(app, ["segment", "--domain", "acme.com", "--query-id", "q1"])
269
+ assert result.exit_code == 0, result.output
270
+ result = runner.invoke(app, ["segment", "--file", str(domains_file), "--query-id", "q2"])
271
+ assert result.exit_code == 0, result.output
272
+ assert captured[0].url.params.get_list("query_id") == ["q1"]
273
+ assert captured[1].url.params.get_list("query_id") == ["q2"]
@@ -217,3 +217,58 @@ def test_match_bulk_local_mode_sent_when_passed(tmp_path, install_build_client:
217
217
  result = runner.invoke(app, ["match", "--file", str(names_file), "--local-mode"])
218
218
  assert result.exit_code == 0, result.output
219
219
  assert captured["request"].url.params.get("local_mode") == "true"
220
+
221
+
222
+ def test_match_single_forwards_min_match_confidence(install_build_client: Callable[[Handler], None]) -> None:
223
+ captured: dict[str, httpx2.Request] = {}
224
+
225
+ def handler(request: httpx2.Request) -> httpx2.Response:
226
+ captured["request"] = request
227
+ return httpx2.Response(200, json={"domain": "acme.com"})
228
+
229
+ install_build_client(handler)
230
+ result = runner.invoke(app, ["match", "Acme", "--min-match-confidence", "80"])
231
+ assert result.exit_code == 0, result.output
232
+ assert captured["request"].url.params.get("min_match_confidence") == "80"
233
+
234
+
235
+ def test_match_bulk_forwards_column_flags_and_confidence(
236
+ tmp_path, install_build_client: Callable[[Handler], None]
237
+ ) -> None:
238
+ names_file = tmp_path / "names.csv"
239
+ names_file.write_text("name,phone,city,state,country,zip\nAcme,555,Austin,TX,US,78701\n")
240
+ captured: dict[str, httpx2.Request] = {}
241
+
242
+ def handler(request: httpx2.Request) -> httpx2.Response:
243
+ captured["request"] = request
244
+ return httpx2.Response(200, json={"task_id": "bm-6"})
245
+
246
+ install_build_client(handler)
247
+ result = runner.invoke(
248
+ app,
249
+ [
250
+ "match",
251
+ "--file",
252
+ str(names_file),
253
+ "--phone-column",
254
+ "phone",
255
+ "--city-column",
256
+ "city",
257
+ "--state-column",
258
+ "state",
259
+ "--country-column",
260
+ "country",
261
+ "--zip-code-column",
262
+ "zip",
263
+ "--min-match-confidence",
264
+ "75",
265
+ ],
266
+ )
267
+ assert result.exit_code == 0, result.output
268
+ params = captured["request"].url.params
269
+ assert params.get("phone_column") == "phone"
270
+ assert params.get("city_column") == "city"
271
+ assert params.get("state_column") == "state"
272
+ assert params.get("country_column") == "country"
273
+ assert params.get("zip_code_column") == "zip"
274
+ assert params.get("min_match_confidence") == "75"
@@ -0,0 +1,99 @@
1
+ """Every SDK request field must be reachable as a first-class CLI flag.
2
+
3
+ Reads the CLI source with ``ast`` rather than invoking typer, so a new field on
4
+ a request model fails here until the matching command forwards it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import pathlib
11
+ from collections.abc import Iterator
12
+
13
+ import pytest
14
+
15
+ import discolike.requests as requests_module
16
+
17
+ CLI_SOURCE_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "discolike_cli"
18
+ REQUEST_BUILDER = "build_request"
19
+ # icp_text was dropped from discover and contacts in 0.1.1 in favor of icp_prompt; see the *_icp_text_*_removed tests.
20
+ DELIBERATELY_OMITTED: dict[str, frozenset[str]] = {
21
+ "DiscoverParams": frozenset({"icp_text"}),
22
+ "ContactsSearchParams": frozenset({"icp_text"}),
23
+ "ContactsCountParams": frozenset({"icp_text"}),
24
+ "ContactFilters": frozenset({"icp_text"}),
25
+ }
26
+ DICT_ONLY_FIELDS: dict[str, frozenset[str]] = {
27
+ "ContactGenerateRequest": frozenset({"initial_contact_counts"}),
28
+ "DiscoGenProcessRequest": frozenset({"previous_discogen_data"}),
29
+ "DiscoGenPersonaProcessRequest": frozenset({"previous_discogen_data"}),
30
+ "SaveResultsRequest": frozenset({"query_params"}),
31
+ }
32
+
33
+
34
+ def _keyword_names(call: ast.Call) -> set[str]:
35
+ return {keyword.arg for keyword in call.keywords if keyword.arg is not None}
36
+
37
+
38
+ def _dict_keys(node: ast.Dict, assignments: dict[str, ast.Call]) -> set[str]:
39
+ keys: set[str] = set()
40
+ for key, value in zip(node.keys, node.values, strict=True):
41
+ if key is None:
42
+ keys |= _forwarded_fields(argument=value, assignments=assignments)
43
+ elif isinstance(key, ast.Constant) and isinstance(key.value, str):
44
+ keys.add(key.value)
45
+ return keys
46
+
47
+
48
+ def _forwarded_fields(argument: ast.expr, assignments: dict[str, ast.Call]) -> set[str]:
49
+ if isinstance(argument, ast.Name) and argument.id in assignments:
50
+ argument = assignments[argument.id]
51
+ if isinstance(argument, ast.Call):
52
+ return _keyword_names(argument)
53
+ if isinstance(argument, ast.Dict):
54
+ return _dict_keys(node=argument, assignments=assignments)
55
+ return set()
56
+
57
+
58
+ def _build_request_sites() -> Iterator[tuple[str, str, set[str]]]:
59
+ for source_file in sorted(CLI_SOURCE_DIR.glob("*.py")):
60
+ tree = ast.parse(source_file.read_text())
61
+ for function in (node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)):
62
+ assignments = {
63
+ node.targets[0].id: node.value
64
+ for node in ast.walk(function)
65
+ if isinstance(node, ast.Assign)
66
+ and len(node.targets) == 1
67
+ and isinstance(node.targets[0], ast.Name)
68
+ and isinstance(node.value, ast.Call)
69
+ }
70
+ for call in (node for node in ast.walk(function) if isinstance(node, ast.Call)):
71
+ callee = call.func.id if isinstance(call.func, ast.Name) else getattr(call.func, "attr", "")
72
+ if callee != REQUEST_BUILDER or not call.args or not isinstance(call.args[0], ast.Name):
73
+ continue
74
+ forwarded: set[str] = set()
75
+ for argument in call.args[1:]:
76
+ forwarded |= _forwarded_fields(argument=argument, assignments=assignments)
77
+ yield f"{source_file.stem}.{function.name}", call.args[0].id, forwarded
78
+
79
+
80
+ SITES = list(_build_request_sites())
81
+
82
+
83
+ @pytest.mark.parametrize(("site", "model_name", "forwarded"), SITES, ids=[f"{s}->{m}" for s, m, _ in SITES])
84
+ def test_cli_forwards_every_sdk_field(site: str, model_name: str, forwarded: set[str]) -> None:
85
+ model = getattr(requests_module, model_name)
86
+ excluded = DICT_ONLY_FIELDS.get(model_name, frozenset()) | DELIBERATELY_OMITTED.get(model_name, frozenset())
87
+ expected = set(model.model_fields) - excluded
88
+ missing = sorted(expected - forwarded)
89
+ assert not missing, f"{site} does not forward {model_name} fields: {', '.join(missing)}"
90
+
91
+
92
+ def test_every_request_model_has_a_cli_command() -> None:
93
+ used = {model_name for _, model_name, _ in SITES}
94
+ declared = {
95
+ name
96
+ for name in dir(requests_module)
97
+ if name.endswith(("Params", "Request")) and hasattr(getattr(requests_module, name), "model_fields")
98
+ }
99
+ assert not sorted(declared - used), f"request models with no CLI command: {sorted(declared - used)}"
File without changes
File without changes
File without changes