discolike-cli 0.3.1__tar.gz → 0.4.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 (49) hide show
  1. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/PKG-INFO +16 -3
  2. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/README.md +14 -1
  3. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/pyproject.toml +3 -3
  4. discolike_cli-0.4.0/src/discolike_cli/_help.py +292 -0
  5. discolike_cli-0.4.0/src/discolike_cli/_inputs.py +64 -0
  6. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/_output.py +32 -1
  7. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/account.py +3 -1
  8. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/auth.py +6 -7
  9. discolike_cli-0.4.0/src/discolike_cli/bulk.py +561 -0
  10. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/company.py +10 -5
  11. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/contacts.py +180 -7
  12. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/discogen.py +31 -5
  13. discolike_cli-0.4.0/src/discolike_cli/discover.py +293 -0
  14. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/enrich.py +23 -13
  15. discolike_cli-0.4.0/src/discolike_cli/main.py +127 -0
  16. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/match.py +25 -1
  17. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/queries.py +11 -2
  18. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_auth.py +4 -3
  19. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_auth_oauth.py +1 -1
  20. discolike_cli-0.4.0/tests/test_bulk_cli.py +660 -0
  21. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_company.py +24 -0
  22. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_contacts_cli.py +178 -9
  23. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_discogen_cli.py +172 -0
  24. discolike_cli-0.4.0/tests/test_discover.py +410 -0
  25. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_enrich_cli.py +57 -0
  26. discolike_cli-0.4.0/tests/test_entrypoint.py +87 -0
  27. discolike_cli-0.4.0/tests/test_file_inputs_cli.py +210 -0
  28. discolike_cli-0.4.0/tests/test_help.py +84 -0
  29. discolike_cli-0.4.0/tests/test_inputs.py +81 -0
  30. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_match_cli.py +55 -0
  31. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_output.py +47 -1
  32. discolike_cli-0.4.0/tests/test_sdk_parity.py +105 -0
  33. discolike_cli-0.3.1/src/discolike_cli/discover.py +0 -150
  34. discolike_cli-0.3.1/src/discolike_cli/main.py +0 -74
  35. discolike_cli-0.3.1/tests/test_discover.py +0 -147
  36. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/.gitignore +0 -0
  37. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/LICENSE +0 -0
  38. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/__init__.py +0 -0
  39. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/_loopback.py +0 -0
  40. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/email.py +0 -0
  41. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/providers.py +0 -0
  42. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/py.typed +0 -0
  43. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/src/discolike_cli/signup.py +0 -0
  44. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/conftest.py +0 -0
  45. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_account.py +0 -0
  46. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_email_cli.py +0 -0
  47. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_providers_cli.py +0 -0
  48. {discolike_cli-0.3.1 → discolike_cli-0.4.0}/tests/test_queries_cli.py +0 -0
  49. {discolike_cli-0.3.1 → discolike_cli-0.4.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.3.1
3
+ Version: 0.4.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
@@ -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.4.0
29
29
  Requires-Dist: rich>=13.0
30
30
  Requires-Dist: typer>=0.12
31
31
  Description-Content-Type: text/markdown
@@ -67,12 +67,25 @@ discolike company data stripe.com
67
67
  discolike extract https://stripe.com/enterprise
68
68
  ```
69
69
 
70
- Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
70
+ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
71
+
72
+ ### Volume pulls
73
+
74
+ `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:
75
+
76
+ ```bash
77
+ discolike bulk companies --params-file form.json --max-companies 50000 --run-name agencies --out companies.csv
78
+ discolike bulk estimate --domains-file companies.csv --per-company 10 # free size check
79
+ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary "growth marketing" --out contacts.csv
80
+ ```
81
+
82
+ `companies` saves each page as an exclusion list (`<run-name>-round-N`) and excludes it from the next page; rerunning with the same `--out` resumes from the CSV. `contacts` slices the domain list at `10000 / per-company` domains per call and records finished slices in `<out>.checkpoint`. Both keep one call in flight under `--rate-limit` (default 10/min, the Pro rate on `/discover` and `/contacts`), retry on 429/5xx, and print a JSON summary at the end. Filters come from `--params-file`, `--param` and the common flags; the paging fields are managed for you.
71
83
 
72
84
  ### Conventions
73
85
 
74
86
  - Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr.
75
87
  - Pass `--format table` for a human-readable table — used automatically when stdout is a TTY.
88
+ - Volume inputs come from files: `--domains-file companies.csv` (a `domain` column, or one domain per line) on `queries create-exclusion-list`, `contacts discover|search|count|generate`, `discogen run` and `validate-icp`; `--params-file form.json` (a JSON object of API parameter names, e.g. an app form) on `discover`, `count` and `contacts discover|search|count`; `--exclude-domains-file` on `discover`. Precedence: file < `--param` < flags.
76
89
  - Async endpoints (`match --file`, `discogen run`, `discogen run-personas`, `segment`, `validate-icp`) take `--wait` to block until the job finishes. Without it, you get a `task_id` back to poll with `discolike discogen status <task_id> --family <family>`. `append` is synchronous — it returns enriched rows directly (or writes CSV bytes to `--output`).
77
90
 
78
91
  ### Exit codes
@@ -35,12 +35,25 @@ discolike company data stripe.com
35
35
  discolike extract https://stripe.com/enterprise
36
36
  ```
37
37
 
38
- Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
38
+ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
39
+
40
+ ### Volume pulls
41
+
42
+ `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:
43
+
44
+ ```bash
45
+ discolike bulk companies --params-file form.json --max-companies 50000 --run-name agencies --out companies.csv
46
+ discolike bulk estimate --domains-file companies.csv --per-company 10 # free size check
47
+ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary "growth marketing" --out contacts.csv
48
+ ```
49
+
50
+ `companies` saves each page as an exclusion list (`<run-name>-round-N`) and excludes it from the next page; rerunning with the same `--out` resumes from the CSV. `contacts` slices the domain list at `10000 / per-company` domains per call and records finished slices in `<out>.checkpoint`. Both keep one call in flight under `--rate-limit` (default 10/min, the Pro rate on `/discover` and `/contacts`), retry on 429/5xx, and print a JSON summary at the end. Filters come from `--params-file`, `--param` and the common flags; the paging fields are managed for you.
39
51
 
40
52
  ### Conventions
41
53
 
42
54
  - Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr.
43
55
  - Pass `--format table` for a human-readable table — used automatically when stdout is a TTY.
56
+ - Volume inputs come from files: `--domains-file companies.csv` (a `domain` column, or one domain per line) on `queries create-exclusion-list`, `contacts discover|search|count|generate`, `discogen run` and `validate-icp`; `--params-file form.json` (a JSON object of API parameter names, e.g. an app form) on `discover`, `count` and `contacts discover|search|count`; `--exclude-domains-file` on `discover`. Precedence: file < `--param` < flags.
44
57
  - Async endpoints (`match --file`, `discogen run`, `discogen run-personas`, `segment`, `validate-icp`) take `--wait` to block until the job finishes. Without it, you get a `task_id` back to poll with `discolike discogen status <task_id> --family <family>`. `append` is synchronous — it returns enriched rows directly (or writes CSV bytes to `--output`).
45
58
 
46
59
  ### Exit codes
@@ -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.4.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.10"
13
13
  authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
14
14
  dependencies = [
15
- "discolike==0.3.1",
15
+ "discolike==0.4.0",
16
16
  "typer>=0.12",
17
17
  "rich>=13.0",
18
18
  ]
@@ -34,7 +34,7 @@ classifiers = [
34
34
  ]
35
35
 
36
36
  [project.scripts]
37
- discolike = "discolike_cli.main:app"
37
+ discolike = "discolike_cli.main:run"
38
38
 
39
39
  [project.urls]
40
40
  Homepage = "https://www.discolike.com"
@@ -0,0 +1,292 @@
1
+ """Help-text epilogs that spell out the CLI's output contract for agents and scripts.
2
+
3
+ Every constant here is plain text (no Rich markup) so it renders identically in a
4
+ terminal, in a pipe, and in a test assertion. Response shapes mirror the SDK models in
5
+ ``discolike.resources``; keep them in sync when a model changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import textwrap
11
+
12
+ import typer
13
+ from typer._click import Context
14
+ from typer._click import HelpFormatter
15
+ from typer.core import TyperCommand
16
+ from typer.core import TyperGroup
17
+
18
+ from discolike._config import ENV_API_KEY
19
+ from discolike._config import config_path
20
+
21
+ EPILOG_INDENT = " "
22
+
23
+
24
+ def _echo_verbatim(epilog: str) -> None:
25
+ # Typer's rich formatter reflows epilog paragraphs into prose, which destroys the
26
+ # aligned tables below. Print the epilog ourselves, exactly as written.
27
+ typer.echo(textwrap.indent(epilog.rstrip("\n"), EPILOG_INDENT))
28
+
29
+
30
+ class ContractCommand(TyperCommand):
31
+ """A command whose epilog is printed verbatim, not reflowed."""
32
+
33
+ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
34
+ epilog, self.epilog = self.epilog, None
35
+ try:
36
+ super().format_help(ctx, formatter)
37
+ finally:
38
+ self.epilog = epilog
39
+ if epilog:
40
+ _echo_verbatim(epilog)
41
+
42
+
43
+ class ContractGroup(TyperGroup):
44
+ """A command group whose epilog is printed verbatim, not reflowed."""
45
+
46
+ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
47
+ epilog, self.epilog = self.epilog, None
48
+ try:
49
+ super().format_help(ctx, formatter)
50
+ finally:
51
+ self.epilog = epilog
52
+ if epilog:
53
+ _echo_verbatim(epilog)
54
+
55
+
56
+ _CONFIG_PATH_HINT = "$XDG_CONFIG_HOME/discolike/config.json (default ~/.config/discolike/config.json)"
57
+
58
+ MAIN_EPILOG = f"""\
59
+ Output contract:
60
+ Success JSON on stdout, exit 0. JSON is the default whenever stdout is not a
61
+ TTY; --format json forces it and --format table renders a table for
62
+ humans. Progress lines from --wait go to stderr only.
63
+ Failure {{"error", "code", "message", "status_code", "exit_code"}} on stderr
64
+ with a non-zero exit code. Branch on "code" (stable) or the exit code;
65
+ "error" is the SDK exception class name. rate_limited adds "retry_after".
66
+ Determinism No spinners, colors, or prompts on stdout when piped.
67
+
68
+ Exit codes:
69
+ 0 success
70
+ 1 server_error, job_failed, job_timeout, or any other unrecoverable error
71
+ 2 validation_error input rejected before or by the API (bad flag, enum, range)
72
+ 3 auth_required no credential found: run `discolike auth login`
73
+ auth_invalid credential rejected (401/403); plan_access needs a higher plan
74
+ 4 rate_limited HTTP 429; wait "retry_after" seconds, then retry
75
+ 5 network_error could not reach the API
76
+ 6 not_found HTTP 404
77
+
78
+ Environment:
79
+ {ENV_API_KEY} API key; overrides the config file written by `discolike auth login`.
80
+ Config file {_CONFIG_PATH_HINT}
81
+ (resolved now: {config_path()})
82
+
83
+ Examples:
84
+ $ discolike count --country DE --phrase-match "managed detection" --format json | jq .count
85
+ $ discolike discover --icp-prompt "cybersecurity for SMBs" --max-records 100 --format json | jq -r '.[].domain'
86
+ $ discolike validate-icp --icp "sells to hospitals" --domain a.com --domain b.com --wait --format json | jq '.'
87
+ """
88
+
89
+ _COMPANY_ROW = """\
90
+ {"domain": <string>, "name": <string>, "similarity": <0-1>, "score": <int>,
91
+ "description": <string>, "employees": <string>, "revenue_range": <string>,
92
+ "address": {"city", "state", "country"}, "industry_groups": {<name>: <weight>},
93
+ "business_model": {<name>: <weight>}, "mx_provider": <string>, ...}"""
94
+
95
+ _JOB_SUBMITTED = """\
96
+ Without --wait:
97
+ {"task_id": <string>, "task_family": <string>, "hint": "poll with: discolike discogen status ..."}"""
98
+
99
+ _JOB_ERRORS = """\
100
+ Common errors:
101
+ validation_error (exit 2) bad flag value, empty domain list, or an unknown --model.
102
+ auth_required (exit 3) no credential; run `discolike auth login`.
103
+ plan_access (exit 3) the plan does not include this job type.
104
+ job_failed (exit 1) the job finished with an error (only with --wait).
105
+ job_timeout (exit 1) --timeout elapsed before the job finished; poll with `discolike discogen status`."""
106
+
107
+ _SEARCH_ERRORS = """\
108
+ Common errors:
109
+ validation_error (exit 2) unknown filter value, bad range, or more than 10 seed domains.
110
+ auth_required (exit 3) no credential; run `discolike auth login`.
111
+ plan_access (exit 3) the plan does not allow this filter or record count.
112
+ rate_limited (exit 4) HTTP 429; retry after "retry_after" seconds."""
113
+
114
+ COMMAND_EPILOGS: dict[str, str] = {
115
+ "discover": f"""\
116
+ Output (success, exit 0):
117
+ [
118
+ {_COMPANY_ROW}
119
+ ]
120
+ One row per matched company, ranked by similarity. Billed per 1,000 new records;
121
+ records seen in the last 90 days are free. Count first: `discolike count` is free.
122
+
123
+ {_SEARCH_ERRORS}
124
+ """,
125
+ "count": f"""\
126
+ Output (success, exit 0):
127
+ {{"count": <int>}}
128
+ Free. Same filters as `discover`; run it before any large pull.
129
+
130
+ {_SEARCH_ERRORS}
131
+ """,
132
+ "match": """\
133
+ Output (success, exit 0):
134
+ Single (--name):
135
+ {"query": {"name", "country", "state", "city", "zip", "phones"},
136
+ "matches": [{"domain": <string>, "name": <string>, "match_confidence": <0-100>, ...}]}
137
+ Bulk (--file) without --wait:
138
+ {"task_id": <string>, "task_family": <string>, "hint": <string>}
139
+ Bulk with --wait: one row per input row; rows the backend could not process carry
140
+ "match_error": "search_failed", which is not the same as no match.
141
+
142
+ Common errors:
143
+ validation_error (exit 2) neither --name nor --file given, or a named column is missing.
144
+ auth_required (exit 3) no credential; run `discolike auth login`.
145
+ job_timeout (exit 1) --timeout elapsed on a bulk match; poll with `discolike discogen status`.
146
+ """,
147
+ "append": """\
148
+ Output (success, exit 0):
149
+ JSON (default): [{"domain": <string>, <dataset fields>...}] one row per input row.
150
+ --csv --output FILE: {"written": <path>, "bytes": <int>} and the CSV is on disk.
151
+ Datasets: bizdata, domain_status, redirects, growth, vendors, subdomains.
152
+ Up to 10,000 rows per request; billed per new company record.
153
+
154
+ Common errors:
155
+ validation_error (exit 2) missing --domain-column, unknown --dataset, or --csv without --output.
156
+ auth_required (exit 3) no credential; run `discolike auth login`.
157
+ plan_access (exit 3) a requested dataset is not on the plan.
158
+ """,
159
+ "validate-icp": f"""\
160
+ Output (success, exit 0):
161
+ {_JOB_SUBMITTED}
162
+ With --wait: the results object keyed by domain:
163
+ {{<domain>: {{"Fit": "Yes"|"No", "Confidence": "high"|"medium"|"low", "Reasoning": <string>}}, ...}}
164
+ Runs on the account's own LLM provider key.
165
+ With --integration-id native-icp: DiscoLike's own ICP-fit model, no LLM key or cost and no
166
+ web search. Keys become "ICP Fit" ("Yes"|"No" at a 0.50 threshold on the score), "ICP Score"
167
+ (the calibrated probability, 0.00-1.00 as a string) and "Reasoning", always null there: the
168
+ model returns a score, not an explanation. A 400 when the ICP text yields no Mandatory /
169
+ Reject if / Nice-to-have prompt, a 503 when no ICP-fit engine is available.
170
+
171
+ {_JOB_ERRORS}
172
+ """,
173
+ "segment": f"""\
174
+ Output (success, exit 0):
175
+ {_JOB_SUBMITTED}
176
+ With --wait: one BizData row per active domain, plus its cluster:
177
+ [{{"domain", "name", "score", "segment_id": <int, -1 = unclustered>,
178
+ "segment_description": <string>, "probability": <0-1>, ...}}]
179
+ Closed or unindexed input domains are omitted.
180
+
181
+ {_JOB_ERRORS}
182
+ """,
183
+ "extract": """\
184
+ Output (success, exit 0):
185
+ {"text": <string>, "language": <ISO 639-1>}
186
+ Fetches the page live; use it to confirm a seed domain is the right company.
187
+
188
+ Common errors:
189
+ validation_error (exit 2) neither a URL argument nor --domain given.
190
+ auth_required (exit 3) no credential; run `discolike auth login`.
191
+ not_found (exit 6) the page could not be fetched.
192
+ """,
193
+ "signup": """\
194
+ Output (success, exit 0):
195
+ {"status": <string>, "email": <string>, "org_domain": <string>,
196
+ "org_status": <string>, "next_step": <string>}
197
+ No credential is returned. Relay "next_step" to the person: they confirm by email,
198
+ log in, pick a plan, then run `discolike auth login`. No login is needed for this call.
199
+
200
+ Common errors:
201
+ validation_error (exit 2) free-mail or disposable email domain, or a missing name.
202
+ error (exit 1) HTTP 409: the account already exists; send them to log in.
203
+ """,
204
+ "contacts search": """\
205
+ Output (success, exit 0):
206
+ [{"persona_id": <int>, "domain": <string>, "name": <string>, "title": <string>,
207
+ "email": <string>, ...}]
208
+ Billed per new contact record. `discolike contacts count` with the same filters is free.
209
+
210
+ Common errors:
211
+ validation_error (exit 2) unknown seniority/department value or a bad range.
212
+ auth_required (exit 3) no credential; run `discolike auth login`.
213
+ plan_access (exit 3) contacts are not included on the plan.
214
+ """,
215
+ "contacts generate": f"""\
216
+ Output (success, exit 0):
217
+ {_JOB_SUBMITTED}
218
+ With --wait: results keyed by domain, zero or more candidate rows each:
219
+ {{<domain>: [{{"name", "title", "department", "seniority", "email", "linkedin_url",
220
+ "skills", "phone": [{{"phone", "type"}}], "email_pattern", "email_pattern_confidence",
221
+ "email_pattern_guess"}}], ...}}
222
+ Rows are candidates; email is set only when publicly verifiable.
223
+ Runs live web search on the account's own LLM and search provider keys; no platform billing.
224
+
225
+ {_JOB_ERRORS}
226
+ """,
227
+ "discogen run": f"""\
228
+ Output (success, exit 0):
229
+ {_JOB_SUBMITTED}
230
+ With --wait: one entry per domain with the model's structured answer to --query.
231
+ Send the whole list in one call; parallel jobs share the provider key and rate-limit each other.
232
+
233
+ {_JOB_ERRORS}
234
+ """,
235
+ "discogen status": """\
236
+ Output (success, exit 0):
237
+ {"status": "pending"|"running"|"completed"|"failed", "progress": <0-100>,
238
+ "results": <any>, "warnings": [<string>], "estimated_cost": <float>,
239
+ "cost_metadata": {<provider/model>: {...}, "search_provider": {...}}}
240
+ "results" is null until the job completes. Pass --family for non-DiscoGen jobs.
241
+
242
+ Common errors:
243
+ validation_error (exit 2) unknown --family value.
244
+ auth_required (exit 3) no credential; run `discolike auth login`.
245
+ not_found (exit 6) no job with that task id in that family.
246
+ """,
247
+ "queries create-exclusion-list": """\
248
+ Output (success, exit 0):
249
+ {"query_id": <string>, "query_name": <string>, "action": <string>,
250
+ "domain_count": <int>, "persona_id_count": <int>, "row_count": <int>, "tags": [<string>]}
251
+ Pass "query_id" as --exclusion-query-id (or --inclusion-query-id) on the next discover.
252
+ Minimum 20 domains; lists hold up to 250,000 domains on every plan.
253
+
254
+ Common errors:
255
+ validation_error (exit 2) fewer than 20 domains, or a missing --name.
256
+ auth_required (exit 3) no credential; run `discolike auth login`.
257
+ """,
258
+ "auth login": """\
259
+ Output (success, exit 0):
260
+ Browser (default): {"logged_in": true, "method": "oauth", "expires_at": <ISO 8601>}
261
+ --api-key KEY: {"logged_in": true, "source": "api_key"}
262
+ The credential is saved to the config file; later commands read it automatically.
263
+ Progress lines (the URL to open, browser wait) go to stderr; only the JSON above is on stdout.
264
+ Headless: --no-browser prints the URL to open; --port pins the loopback port for SSH.
265
+
266
+ Common errors:
267
+ auth_invalid (exit 3) the API key was rejected.
268
+ error (exit 1) {"error": "LoginError", ...}: timeout, denied consent, or state mismatch.
269
+ """,
270
+ "auth status": """\
271
+ Output (success, exit 0):
272
+ API key: {"source": "option"|"env"|"config", "method": "api_key", "api_key": <masked>, "valid": true}
273
+ OAuth: {"source": "config", "method": "oauth", "expires_at": <ISO 8601>, "expired": <bool>, "valid": true}
274
+ Exit 0 here is the whole proof that the CLI is signed in.
275
+
276
+ Common errors:
277
+ auth_required (exit 3) no credential anywhere; run `discolike auth login`.
278
+ auth_invalid (exit 3) the stored credential no longer verifies; log in again.
279
+ """,
280
+ "account usage": """\
281
+ Output (success, exit 0):
282
+ {"requests_mtd": <int>, "records_mtd": <int>, "spend_mtd": <float>}
283
+ Month-to-date. Report it before any paid flow so the person knows the baseline.
284
+
285
+ Common errors:
286
+ auth_required (exit 3) no credential; run `discolike auth login`.
287
+ """,
288
+ }
289
+
290
+
291
+ def epilog(name: str) -> str:
292
+ return COMMAND_EPILOGS[name]
@@ -0,0 +1,64 @@
1
+ """File-backed inputs for volume flows: domain lists and whole-request JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import pathlib
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+ DOMAIN_COLUMN = "domain"
13
+ WWW_PREFIX = "www."
14
+
15
+
16
+ def read_domains_file(path: pathlib.Path) -> list[str]:
17
+ """CSV with a ``domain`` column, or one domain per line (first column when no header).
18
+
19
+ Domains are stripped, lower-cased, ``www.``-less and de-duplicated in file order.
20
+ """
21
+ try:
22
+ with path.open(newline="") as handle:
23
+ rows = list(csv.reader(handle))
24
+ except FileNotFoundError as exc:
25
+ raise typer.BadParameter(f"domains file not found: {path}") from exc
26
+ except (OSError, UnicodeDecodeError) as exc:
27
+ raise typer.BadParameter(f"domains file {path} could not be read: {exc}") from exc
28
+ header = [cell.strip().lower() for cell in rows[0]] if rows else []
29
+ column = header.index(DOMAIN_COLUMN) if DOMAIN_COLUMN in header else 0
30
+ body = rows[1:] if DOMAIN_COLUMN in header else rows
31
+ seen: set[str] = set()
32
+ domains: list[str] = []
33
+ for row in body:
34
+ if column >= len(row):
35
+ continue
36
+ domain = row[column].strip().lower().removeprefix(WWW_PREFIX)
37
+ if domain and domain not in seen:
38
+ seen.add(domain)
39
+ domains.append(domain)
40
+ if not domains:
41
+ raise typer.BadParameter(f"domains file has no domains: {path}")
42
+ return domains
43
+
44
+
45
+ def read_params_file(path: pathlib.Path) -> dict[str, Any]:
46
+ """JSON object of API parameter names, e.g. an app form copied over verbatim."""
47
+ try:
48
+ loaded = json.loads(path.read_text())
49
+ except FileNotFoundError as exc:
50
+ raise typer.BadParameter(f"params file not found: {path}") from exc
51
+ except (OSError, UnicodeDecodeError) as exc:
52
+ raise typer.BadParameter(f"params file {path} could not be read: {exc}") from exc
53
+ except json.JSONDecodeError as exc:
54
+ raise typer.BadParameter(f"params file {path} must contain valid JSON: {exc}") from exc
55
+ if not isinstance(loaded, dict):
56
+ raise typer.BadParameter(f"params file {path} must contain a JSON object")
57
+ return loaded
58
+
59
+
60
+ def merge_domains(inline: list[str] | None, file: pathlib.Path | None) -> list[str] | None:
61
+ """Inline ``--domain`` values first, then the file's, or ``None`` when neither was given."""
62
+ if file is None:
63
+ return inline
64
+ return [*(inline or []), *read_domains_file(file)]
@@ -16,12 +16,16 @@ import typer
16
16
  from rich.console import Console
17
17
  from rich.table import Table
18
18
 
19
+ from discolike._config import NO_CREDENTIAL_MESSAGE
19
20
  from discolike._exceptions import APIConnectionError
20
21
  from discolike._exceptions import AuthenticationError
21
22
  from discolike._exceptions import DiscolikeError
23
+ from discolike._exceptions import JobFailedError
24
+ from discolike._exceptions import JobTimeoutError
22
25
  from discolike._exceptions import NotFoundError
23
26
  from discolike._exceptions import PlanAccessError
24
27
  from discolike._exceptions import RateLimitError
28
+ from discolike._exceptions import ServerError
25
29
  from discolike._exceptions import ValidationError
26
30
  from discolike._models import DiscolikeModel
27
31
 
@@ -40,6 +44,30 @@ EXIT_CODES: dict[type, int] = {
40
44
  }
41
45
  DEFAULT_EXIT_CODE = 1
42
46
 
47
+ # Stable, snake_case error codes for agents and scripts to branch on. The class
48
+ # name in `error` is kept for backwards compatibility; `code` is the contract.
49
+ ERROR_CODES: dict[type, str] = {
50
+ ValidationError: "validation_error",
51
+ AuthenticationError: "auth_invalid",
52
+ PlanAccessError: "plan_access",
53
+ RateLimitError: "rate_limited",
54
+ APIConnectionError: "network_error",
55
+ NotFoundError: "not_found",
56
+ ServerError: "server_error",
57
+ JobFailedError: "job_failed",
58
+ JobTimeoutError: "job_timeout",
59
+ }
60
+ DEFAULT_ERROR_CODE = "error"
61
+ AUTH_REQUIRED_CODE = "auth_required"
62
+
63
+
64
+ def error_code(exc: DiscolikeError) -> str:
65
+ # The SDK raises the same exception class both when no credential exists and when a stored
66
+ # OAuth credential fails to refresh; only the former is "run `discolike auth login`" territory.
67
+ if isinstance(exc, AuthenticationError) and str(exc) == NO_CREDENTIAL_MESSAGE:
68
+ return AUTH_REQUIRED_CODE
69
+ return ERROR_CODES.get(type(exc), DEFAULT_ERROR_CODE)
70
+
43
71
 
44
72
  class _JobStatusLike(Protocol):
45
73
  results: Any
@@ -111,15 +139,18 @@ def emit(data: Any, *, fmt: str | None = None) -> None: # noqa: ANN401 -- accep
111
139
 
112
140
 
113
141
  def fail(exc: DiscolikeError) -> typer.Exit:
142
+ exit_code = EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE)
114
143
  payload: dict[str, Any] = {
115
144
  "error": type(exc).__name__,
145
+ "code": error_code(exc),
116
146
  "message": str(exc),
117
147
  "status_code": exc.status_code,
148
+ "exit_code": exit_code,
118
149
  }
119
150
  if isinstance(exc, RateLimitError) and exc.retry_after is not None:
120
151
  payload["retry_after"] = exc.retry_after
121
152
  print(json.dumps(payload), file=sys.stderr)
122
- return typer.Exit(code=EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE))
153
+ return typer.Exit(code=exit_code)
123
154
 
124
155
 
125
156
  def _accepts_list(model: type[pydantic.BaseModel], name: str) -> bool:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import typer
4
4
 
5
+ from discolike_cli._help import ContractCommand
6
+ from discolike_cli._help import epilog
5
7
  from discolike_cli._output import emit
6
8
  from discolike_cli._output import handle_errors
7
9
 
@@ -10,7 +12,7 @@ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls
10
12
  app = typer.Typer(help="Account usage and quota.")
11
13
 
12
14
 
13
- @app.command("usage")
15
+ @app.command("usage", cls=ContractCommand, epilog=epilog("account usage"))
14
16
  @handle_errors
15
17
  def usage_command(
16
18
  ctx: typer.Context,
@@ -34,6 +34,8 @@ from discolike._oauth import build_authorization_url
34
34
  from discolike._oauth import discover
35
35
  from discolike._oauth import exchange_code
36
36
  from discolike._oauth import register_client
37
+ from discolike_cli._help import ContractCommand
38
+ from discolike_cli._help import epilog
37
39
  from discolike_cli._loopback import CallbackServer
38
40
  from discolike_cli._output import emit
39
41
  from discolike_cli._output import handle_errors
@@ -225,10 +227,10 @@ def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None:
225
227
  key = api_key or passed_globally or typer.prompt("API key", hide_input=True)
226
228
  _verify(ctx, api_key=key)
227
229
  save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": key})
228
- print(json.dumps({"logged_in": True, "source": AUTH_METHOD_API_KEY}), file=sys.stderr)
230
+ emit({"logged_in": True, "source": AUTH_METHOD_API_KEY})
229
231
 
230
232
 
231
- @app.command()
233
+ @app.command(cls=ContractCommand, epilog=epilog("auth login"))
232
234
  @handle_errors
233
235
  def login(
234
236
  ctx: typer.Context,
@@ -263,13 +265,10 @@ def login(
263
265
  credential = _oauth_login(ctx, open_browser=not no_browser, port=port)
264
266
  _verify(ctx, auth=credential)
265
267
  save_credential(credential)
266
- print(
267
- json.dumps({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)}),
268
- file=sys.stderr,
269
- )
268
+ emit({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)})
270
269
 
271
270
 
272
- @app.command()
271
+ @app.command(cls=ContractCommand, epilog=epilog("auth status"))
273
272
  @handle_errors
274
273
  def status(ctx: typer.Context) -> None:
275
274
  """Show which credential is in use (option, env, or config) and verify it against the API."""