discolike-cli 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/PKG-INFO +8 -4
  2. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/README.md +6 -2
  3. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/pyproject.toml +2 -2
  4. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/contacts.py +25 -7
  5. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/prospecting.py +76 -1
  6. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_contacts_cli.py +68 -2
  7. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_prospecting_cli.py +92 -0
  8. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/.gitignore +0 -0
  9. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/LICENSE +0 -0
  10. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/__init__.py +0 -0
  11. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/_help.py +0 -0
  12. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/_inputs.py +0 -0
  13. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/_loopback.py +0 -0
  14. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/_output.py +0 -0
  15. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/account.py +0 -0
  16. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/auth.py +0 -0
  17. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/bulk.py +0 -0
  18. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/company.py +0 -0
  19. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/discogen.py +0 -0
  20. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/discover.py +0 -0
  21. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/email.py +0 -0
  22. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/enrich.py +0 -0
  23. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/main.py +0 -0
  24. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/match.py +0 -0
  25. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/providers.py +0 -0
  26. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/py.typed +0 -0
  27. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/queries.py +0 -0
  28. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/src/discolike_cli/signup.py +0 -0
  29. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/conftest.py +0 -0
  30. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_account.py +0 -0
  31. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_auth.py +0 -0
  32. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_auth_oauth.py +0 -0
  33. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_bulk_cli.py +0 -0
  34. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_company.py +0 -0
  35. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_discogen_cli.py +0 -0
  36. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_discover.py +0 -0
  37. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_email_cli.py +0 -0
  38. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_enrich_cli.py +0 -0
  39. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_entrypoint.py +0 -0
  40. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_file_inputs_cli.py +0 -0
  41. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_help.py +0 -0
  42. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_inputs.py +0 -0
  43. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_match_cli.py +0 -0
  44. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_output.py +0 -0
  45. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_providers_cli.py +0 -0
  46. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_queries_cli.py +0 -0
  47. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/tests/test_sdk_parity.py +0 -0
  48. {discolike_cli-0.5.0 → discolike_cli-0.6.0}/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.5.0
3
+ Version: 0.6.0
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
@@ -24,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.14
24
24
  Classifier: Topic :: Utilities
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.11
27
- Requires-Dist: discolike==0.5.0
27
+ Requires-Dist: discolike==0.6.0
28
28
  Requires-Dist: rich>=13.0
29
29
  Requires-Dist: typer<0.28,>=0.26.1
30
30
  Description-Content-Type: text/markdown
@@ -86,8 +86,10 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary
86
86
  discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1
87
87
  discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1
88
88
  discolike prospecting wait RUN_ID
89
- # Review the proposed plan, then approve the exact version you saw:
90
- discolike prospecting approve RUN_ID --plan-version 1
89
+ # Review the proposed plan; change its engines or size if needed (returns a new plan_version):
90
+ discolike prospecting plan RUN_ID --plan-version 1 --contact-integration-id LLM_ID --search-provider-id SEARCH_ID
91
+ # Then approve the exact version you saw:
92
+ discolike prospecting approve RUN_ID --plan-version 2
91
93
  discolike prospecting list --limit 20
92
94
  discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1
93
95
  discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100
@@ -96,6 +98,8 @@ discolike prospecting cancel RUN_ID
96
98
 
97
99
  `wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes.
98
100
 
101
+ `plan` changes a proposed plan before approval and sends only the flags you pass: `--contact-integration-id`, `--validation-integration-id` and `--search-provider-id` take option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`--search-provider-id none` skips web research), and `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit) resize or re-scope it. When your organization has several AI or search providers and none is the default, the plan shows that engine with `selected: null` and `approve` returns 409 until `plan` picks one. The run comes back re-estimated at a new `plan_version`; approve that one.
102
+
99
103
  `start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), another round of companies when the ones checked yield too few contacts (`top_up`), and, on wave runs, the researched contacts found before their emails are looked up (`contacts_review`, asked with `--auto` too; contacts already in DiscoLike's database get their lookups during research, which approving the plan starts; picking "Find emails" by number sends the question's `seq`, which the API needs to accept it). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text "<reply>"` (add `--question-seq` with the question's `seq` to answer only that question) and run `wait` again.
100
104
 
101
105
  Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 3; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges.
@@ -55,8 +55,10 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary
55
55
  discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1
56
56
  discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1
57
57
  discolike prospecting wait RUN_ID
58
- # Review the proposed plan, then approve the exact version you saw:
59
- discolike prospecting approve RUN_ID --plan-version 1
58
+ # Review the proposed plan; change its engines or size if needed (returns a new plan_version):
59
+ discolike prospecting plan RUN_ID --plan-version 1 --contact-integration-id LLM_ID --search-provider-id SEARCH_ID
60
+ # Then approve the exact version you saw:
61
+ discolike prospecting approve RUN_ID --plan-version 2
60
62
  discolike prospecting list --limit 20
61
63
  discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1
62
64
  discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100
@@ -65,6 +67,8 @@ discolike prospecting cancel RUN_ID
65
67
 
66
68
  `wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes.
67
69
 
70
+ `plan` changes a proposed plan before approval and sends only the flags you pass: `--contact-integration-id`, `--validation-integration-id` and `--search-provider-id` take option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`--search-provider-id none` skips web research), and `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit) resize or re-scope it. When your organization has several AI or search providers and none is the default, the plan shows that engine with `selected: null` and `approve` returns 409 until `plan` picks one. The run comes back re-estimated at a new `plan_version`; approve that one.
71
+
68
72
  `start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), another round of companies when the ones checked yield too few contacts (`top_up`), and, on wave runs, the researched contacts found before their emails are looked up (`contacts_review`, asked with `--auto` too; contacts already in DiscoLike's database get their lookups during research, which approving the plan starts; picking "Find emails" by number sends the question's `seq`, which the API needs to accept it). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text "<reply>"` (add `--question-seq` with the question's `seq` to answer only that question) and run `wait` again.
69
73
 
70
74
  Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 3; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "discolike-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
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.5.0",
15
+ "discolike==0.6.0",
16
16
  "typer>=0.26.1,<0.28",
17
17
  "rich>=13.0",
18
18
  ]
@@ -38,7 +38,17 @@ FILTER_STATE_HELP = "Filter by company state/region (repeatable)."
38
38
  NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)."
39
39
  PERSON_STATE_HELP = "Filter by contact state/region (repeatable)."
40
40
  PERSONA_ID_HELP = "Filter by persona ID (repeatable)."
41
- EMAIL_VALIDATED_HELP = "Only contacts with (or without) a validated email address."
41
+ EMAIL_VALIDATED_HELP = (
42
+ "Deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead."
43
+ )
44
+ EMAIL_VALIDATED_WARNING = (
45
+ "warning: --email-validated is deprecated. Treated as has_email: "
46
+ "contact search no longer filters on email verification; verify emails instead."
47
+ )
48
+ NO_EMAIL_VALIDATED_WARNING = (
49
+ "warning: --no-email-validated is deprecated and has no effect. "
50
+ "Contact search no longer filters on email verification; verify emails instead."
51
+ )
42
52
  HAS_PHONE_HELP = "Only contacts with (or without) a phone number."
43
53
  HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number."
44
54
  HAS_LINKEDIN_HELP = "Only contacts with (or without) a LinkedIn profile."
@@ -50,6 +60,17 @@ RESULTS_BY_COMPANY_HELP = "Maximum contacts per company domain (default 5; 0 rem
50
60
  INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)."
51
61
  CONSENSUS_HELP = "Number of query vectors to combine for consensus search."
52
62
 
63
+
64
+ def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bool | None) -> bool | None:
65
+ if email_validated is None:
66
+ return has_email
67
+ if not email_validated:
68
+ typer.echo(NO_EMAIL_VALIDATED_WARNING, err=True)
69
+ return has_email
70
+ typer.echo(EMAIL_VALIDATED_WARNING, err=True)
71
+ return True if has_email is None else has_email
72
+
73
+
53
74
  app = typer.Typer(
54
75
  help="Find contacts: search and count by filters, look up or match individuals, and run bulk/generative discovery jobs."
55
76
  )
@@ -137,8 +158,7 @@ def search_command(
137
158
  filter_state=filter_state,
138
159
  negate_filter_state=negate_filter_state,
139
160
  employee_range=employee_range,
140
- has_email=has_email,
141
- email_validated=email_validated,
161
+ has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email),
142
162
  has_phone=has_phone,
143
163
  has_mobile=has_mobile,
144
164
  has_linkedin=has_linkedin,
@@ -238,8 +258,7 @@ def count_command(
238
258
  filter_state=filter_state,
239
259
  negate_filter_state=negate_filter_state,
240
260
  employee_range=employee_range,
241
- has_email=has_email,
242
- email_validated=email_validated,
261
+ has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email),
243
262
  has_phone=has_phone,
244
263
  has_mobile=has_mobile,
245
264
  has_linkedin=has_linkedin,
@@ -413,8 +432,7 @@ def discover_command(
413
432
  filter_state=filter_state,
414
433
  negate_filter_state=negate_filter_state,
415
434
  employee_range=employee_range,
416
- has_email=has_email,
417
- email_validated=email_validated,
435
+ has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email),
418
436
  has_phone=has_phone,
419
437
  has_mobile=has_mobile,
420
438
  has_linkedin=has_linkedin,
@@ -19,6 +19,7 @@ from discolike.requests import ProspectingBrief
19
19
  from discolike.requests import ProspectingGetParams
20
20
  from discolike.requests import ProspectingListParams
21
21
  from discolike.requests import ProspectingMessageRequest
22
+ from discolike.requests import ProspectingPlanSettings
22
23
  from discolike.resources.prospecting import ProspectingMessage
23
24
  from discolike.resources.prospecting import ProspectingRun
24
25
  from discolike_cli._inputs import read_domains_file
@@ -43,6 +44,18 @@ CUSTOMERS_FILE_HELP = (
43
44
  SEED_SEGMENT_HELP = (
44
45
  "A customer segment id from the plan card to expand (repeatable); omitted keeps the card's selection."
45
46
  )
47
+ PLAN_DELIVERABLE_HELP = "leads finds people at the companies; accounts returns the checked companies only."
48
+ PLAN_GOAL_HELP = (
49
+ "leads keeps adding rounds until the target is met; companies works only --target-companies matching companies."
50
+ )
51
+ SEARCH_PROVIDER_HELP = "An option id from the plan's search_provider; none skips web research."
52
+ SPEND_HELP_SUFFIX = "0 removes the limit."
53
+ PLAN_HELP = (
54
+ "Change a proposed plan's engines, size, deliverable or spending limits before approving it. Flags left out "
55
+ "keep their current choice; engine ids come from the latest plan message's contact_engine, "
56
+ "company_check_engine and search_provider options, and an engine shown with selected null must be picked "
57
+ "here before approve. The run comes back re-estimated at a new plan_version: approve that one."
58
+ )
46
59
  RESULTS_SEGMENT_HELP = "Group the finished results into segments; omitted keeps the brief's setting."
47
60
  QUESTION_SEQ_HELP = (
48
61
  "The seq of the question this suggested reply answers; if a newer question was posted since, it answers none."
@@ -322,7 +335,10 @@ def approve_command(
322
335
  seed_segment: list[int] | None = typer.Option(None, "--seed-segment", help=SEED_SEGMENT_HELP),
323
336
  segment: bool | None = typer.Option(None, "--segment/--no-segment", help=RESULTS_SEGMENT_HELP),
324
337
  ) -> None:
325
- """Approve the reviewed plan version and start research."""
338
+ """Approve the reviewed plan version and start research.
339
+
340
+ A plan with an engine left unselected (selected null) returns 409 until `prospecting plan` picks one.
341
+ """
326
342
  from discolike_cli.main import get_client
327
343
 
328
344
  emit(
@@ -342,6 +358,65 @@ def approve_command(
342
358
  )
343
359
 
344
360
 
361
+ @app.command("plan", help=PLAN_HELP)
362
+ @handle_errors
363
+ def plan_command(
364
+ ctx: typer.Context,
365
+ run_id: str = typer.Argument(...),
366
+ plan_version: int = typer.Option(..., "--plan-version", min=1, help="The plan version you reviewed."),
367
+ contact_integration_id: str | None = typer.Option(
368
+ None, "--contact-integration-id", help="An option id from the plan's contact_engine."
369
+ ),
370
+ validation_integration_id: str | None = typer.Option(
371
+ None, "--validation-integration-id", help="An option id from the plan's company_check_engine."
372
+ ),
373
+ search_provider_id: str | None = typer.Option(None, "--search-provider-id", help=SEARCH_PROVIDER_HELP),
374
+ target_companies: int | None = typer.Option(
375
+ None, "--target-companies", min=1, max=10000, help="Qualified companies to aim for."
376
+ ),
377
+ contacts_per_company: int | None = typer.Option(
378
+ None, "--contacts-per-company", min=1, max=10, help="Most contacts to find at each company."
379
+ ),
380
+ deliverable: str | None = typer.Option(None, "--deliverable", help=PLAN_DELIVERABLE_HELP),
381
+ goal: str | None = typer.Option(None, "--goal", help=PLAN_GOAL_HELP),
382
+ max_spend_usd: float | None = typer.Option(
383
+ None,
384
+ "--max-spend-usd",
385
+ min=0,
386
+ help=f"USD limit on DiscoLike records and per-call fees at your plan's rates; {SPEND_HELP_SUFFIX}",
387
+ ),
388
+ max_provider_spend_usd: float | None = typer.Option(
389
+ None,
390
+ "--max-provider-spend-usd",
391
+ min=0,
392
+ help=f"USD limit on your own AI and search provider keys; {SPEND_HELP_SUFFIX}",
393
+ ),
394
+ ) -> None:
395
+ from discolike_cli.main import get_client
396
+
397
+ emit(
398
+ get_client(ctx).prospecting.update_plan(
399
+ run_id,
400
+ build_request(
401
+ ProspectingPlanSettings,
402
+ _merge_params(
403
+ None,
404
+ plan_version=plan_version,
405
+ contact_integration_id=contact_integration_id,
406
+ validation_integration_id=validation_integration_id,
407
+ search_provider_id=search_provider_id,
408
+ target_companies=target_companies,
409
+ contacts_per_company=contacts_per_company,
410
+ deliverable=deliverable,
411
+ goal=goal,
412
+ max_spend_usd=max_spend_usd,
413
+ max_provider_spend_usd=max_provider_spend_usd,
414
+ ),
415
+ ),
416
+ )
417
+ )
418
+
419
+
345
420
  @app.command("message")
346
421
  @handle_errors
347
422
  def message_command(
@@ -6,6 +6,7 @@ from collections.abc import Callable
6
6
  import httpx2
7
7
  import pytest
8
8
  from typer.testing import CliRunner
9
+ from typer.testing import Result
9
10
 
10
11
  from discolike_cli.main import app
11
12
  from discolike_testkit import Handler
@@ -394,7 +395,8 @@ def _assert_new_contact_filters(params: httpx2.QueryParams) -> None:
394
395
  assert params.get_list("persona_id") == ["7"]
395
396
  assert params.get_list("filter_state") == ["TX"]
396
397
  assert params.get_list("negate_filter_state") == ["NY"]
397
- assert params.get("email_validated") == "true"
398
+ assert "email_validated" not in params
399
+ assert params.get("has_email") == "true"
398
400
  assert params.get("has_phone") == "true"
399
401
  assert params.get("has_mobile") == "false"
400
402
  assert params.get("has_linkedin") == "true"
@@ -455,7 +457,7 @@ def test_contacts_discover_forwards_every_new_flag(install_build_client: Callabl
455
457
  "persona_id": [7],
456
458
  "filter_state": ["TX"],
457
459
  "negate_filter_state": ["NY"],
458
- "email_validated": True,
460
+ "has_email": True,
459
461
  "has_phone": True,
460
462
  "has_mobile": False,
461
463
  "has_linkedin": True,
@@ -469,6 +471,70 @@ def test_contacts_discover_forwards_every_new_flag(install_build_client: Callabl
469
471
  }
470
472
 
471
473
 
474
+ EMAIL_VALIDATED_RESPONSES = {
475
+ "search": [],
476
+ "count": {"count": 1},
477
+ "discover": {"results": {}, "total_contacts": 0},
478
+ }
479
+
480
+
481
+ def _invoke_contacts(
482
+ install_build_client: Callable[[Handler], None], command: str, *flags: str
483
+ ) -> tuple[Result, dict[str, object]]:
484
+ captured: list[httpx2.Request] = []
485
+
486
+ def handler(request: httpx2.Request) -> httpx2.Response:
487
+ captured.append(request)
488
+ return httpx2.Response(200, json=EMAIL_VALIDATED_RESPONSES[command])
489
+
490
+ install_build_client(handler)
491
+ result = runner.invoke(app, ["contacts", command, "--name", "Jane", *flags])
492
+ assert result.exit_code == 0, result.output
493
+ (request,) = captured
494
+ if request.method == "GET":
495
+ return result, {
496
+ key: json.loads(value) if value in ("true", "false") else value for key, value in request.url.params.items()
497
+ }
498
+ return result, json.loads(request.content)
499
+
500
+
501
+ @pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES))
502
+ def test_contacts_email_validated_warns_and_sends_has_email(
503
+ install_build_client: Callable[[Handler], None], command: str
504
+ ) -> None:
505
+ result, sent = _invoke_contacts(install_build_client, command, "--email-validated")
506
+ assert "--email-validated is deprecated. Treated as has_email" in result.stderr
507
+ assert sent["has_email"] is True
508
+ assert "email_validated" not in sent
509
+
510
+
511
+ @pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES))
512
+ def test_contacts_no_email_validated_warns_and_sends_nothing(
513
+ install_build_client: Callable[[Handler], None], command: str
514
+ ) -> None:
515
+ result, sent = _invoke_contacts(install_build_client, command, "--no-email-validated")
516
+ assert "--no-email-validated is deprecated and has no effect" in result.stderr
517
+ assert "has_email" not in sent
518
+ assert "email_validated" not in sent
519
+
520
+
521
+ @pytest.mark.parametrize(("has_email_flag", "expected"), [("--has-email", True), ("--no-has-email", False)])
522
+ def test_contacts_explicit_has_email_wins_over_email_validated(
523
+ install_build_client: Callable[[Handler], None], has_email_flag: str, expected: bool
524
+ ) -> None:
525
+ result, sent = _invoke_contacts(install_build_client, "search", has_email_flag, "--email-validated")
526
+ assert "--email-validated is deprecated" in result.stderr
527
+ assert sent["has_email"] is expected
528
+ assert "email_validated" not in sent
529
+
530
+
531
+ def test_contacts_without_email_validated_does_not_warn(install_build_client: Callable[[Handler], None]) -> None:
532
+ install_build_client(lambda request: httpx2.Response(200, json=[]))
533
+ result = runner.invoke(app, ["contacts", "search", "--name", "Jane"])
534
+ assert result.exit_code == 0, result.output
535
+ assert "deprecated" not in result.stderr
536
+
537
+
472
538
  def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None:
473
539
  captured: dict[str, object] = {}
474
540
 
@@ -189,6 +189,10 @@ def test_message_forwards_question_seq(install_build_client: Callable[[Handler],
189
189
  "arguments",
190
190
  [
191
191
  ["approve", RUN_ID],
192
+ ["plan", RUN_ID],
193
+ ["plan", RUN_ID, "--plan-version", "1", "--contacts-per-company", "11"],
194
+ ["plan", RUN_ID, "--plan-version", "1", "--deliverable", "people"],
195
+ ["plan", RUN_ID, "--plan-version", "1", "--max-spend-usd", "-1"],
192
196
  ["message", RUN_ID, "--text", "Continue"],
193
197
  ["message", RUN_ID, "--text", "Continue", "--idempotency-key", "k", "--question-seq", "0"],
194
198
  ["list", "--limit", "51"],
@@ -455,3 +459,91 @@ def test_approve_sends_the_chosen_segments_and_results_grouping(
455
459
  {"plan_version": 1, "checkpoints": "ask", "seed_segments": [1, 2], "segment": True},
456
460
  {"plan_version": 1, "checkpoints": "ask"},
457
461
  ]
462
+
463
+
464
+ def test_plan_sends_only_the_settings_passed(install_build_client: Callable[[Handler], None]) -> None:
465
+ seen = []
466
+
467
+ def handler(request: httpx2.Request) -> httpx2.Response:
468
+ seen.append(request)
469
+ return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 4})
470
+
471
+ install_build_client(handler)
472
+ result = runner.invoke(
473
+ app,
474
+ [
475
+ "prospecting",
476
+ "plan",
477
+ RUN_ID,
478
+ "--plan-version",
479
+ "3",
480
+ "--contact-integration-id",
481
+ "llm-1",
482
+ "--search-provider-id",
483
+ "none",
484
+ ],
485
+ )
486
+ assert result.exit_code == 0, result.output
487
+ assert seen[0].method == "PATCH"
488
+ assert seen[0].url.path.endswith(f"/prospecting/runs/{RUN_ID}/plan")
489
+ assert json.loads(seen[0].content) == {
490
+ "plan_version": 3,
491
+ "contact_integration_id": "llm-1",
492
+ "search_provider_id": "none",
493
+ }
494
+ printed = json.loads(result.stdout)
495
+ assert (printed["run_id"], printed["plan_version"]) == (RUN_ID, 4)
496
+
497
+
498
+ def test_plan_forwards_every_setting(install_build_client: Callable[[Handler], None]) -> None:
499
+ bodies = []
500
+
501
+ def handler(request: httpx2.Request) -> httpx2.Response:
502
+ bodies.append(json.loads(request.content))
503
+ return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 3})
504
+
505
+ install_build_client(handler)
506
+ result = runner.invoke(
507
+ app,
508
+ [
509
+ "prospecting",
510
+ "plan",
511
+ RUN_ID,
512
+ "--plan-version",
513
+ "2",
514
+ "--contact-integration-id",
515
+ "llm-1",
516
+ "--validation-integration-id",
517
+ "llm-2",
518
+ "--search-provider-id",
519
+ "search-1",
520
+ "--target-companies",
521
+ "250",
522
+ "--contacts-per-company",
523
+ "5",
524
+ "--deliverable",
525
+ "accounts",
526
+ "--goal",
527
+ "companies",
528
+ "--max-spend-usd",
529
+ "0",
530
+ "--max-provider-spend-usd",
531
+ "12.5",
532
+ ],
533
+ )
534
+ assert result.exit_code == 0, result.output
535
+ assert bodies == [
536
+ {
537
+ "plan_version": 2,
538
+ "contact_integration_id": "llm-1",
539
+ "validation_integration_id": "llm-2",
540
+ "search_provider_id": "search-1",
541
+ "target_companies": 250,
542
+ "contacts_per_company": 5,
543
+ "deliverable": "accounts",
544
+ "goal": "companies",
545
+ "max_spend_usd": 0.0,
546
+ "max_provider_spend_usd": 12.5,
547
+ }
548
+ ]
549
+ assert json.loads(result.stdout)["plan_version"] == 3
File without changes
File without changes