discolike-cli 0.3.2__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.
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/PKG-INFO +16 -3
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/README.md +14 -1
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/pyproject.toml +3 -3
- discolike_cli-0.4.0/src/discolike_cli/_help.py +292 -0
- discolike_cli-0.4.0/src/discolike_cli/_inputs.py +64 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/_output.py +32 -1
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/account.py +3 -1
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/auth.py +6 -7
- discolike_cli-0.4.0/src/discolike_cli/bulk.py +561 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/contacts.py +31 -7
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/discogen.py +31 -5
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/discover.py +57 -3
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/enrich.py +12 -10
- discolike_cli-0.4.0/src/discolike_cli/main.py +127 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/queries.py +11 -2
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_auth.py +4 -3
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_auth_oauth.py +1 -1
- discolike_cli-0.4.0/tests/test_bulk_cli.py +660 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_contacts_cli.py +16 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_discogen_cli.py +172 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_discover.py +149 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_enrich_cli.py +20 -0
- discolike_cli-0.4.0/tests/test_entrypoint.py +87 -0
- discolike_cli-0.4.0/tests/test_file_inputs_cli.py +210 -0
- discolike_cli-0.4.0/tests/test_help.py +84 -0
- discolike_cli-0.4.0/tests/test_inputs.py +81 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_output.py +47 -1
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_sdk_parity.py +7 -1
- discolike_cli-0.3.2/src/discolike_cli/main.py +0 -74
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/.gitignore +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/LICENSE +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/_loopback.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/company.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/email.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/match.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/providers.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/src/discolike_cli/signup.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/conftest.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_account.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_company.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_email_cli.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_match_cli.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_providers_cli.py +0 -0
- {discolike_cli-0.3.2 → discolike_cli-0.4.0}/tests/test_queries_cli.py +0 -0
- {discolike_cli-0.3.2 → 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
|
+
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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."""
|