discolike-cli 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ import sys
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+ from typing import ParamSpec
9
+ from typing import Protocol
10
+ from typing import TypeVar
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from discolike._exceptions import APIConnectionError
17
+ from discolike._exceptions import AuthenticationError
18
+ from discolike._exceptions import DiscolikeError
19
+ from discolike._exceptions import NotFoundError
20
+ from discolike._exceptions import PlanAccessError
21
+ from discolike._exceptions import RateLimitError
22
+ from discolike._exceptions import ValidationError
23
+ from discolike._models import DiscolikeModel
24
+
25
+ P = ParamSpec("P")
26
+ R = TypeVar("R")
27
+
28
+ EXIT_CODES: dict[type, int] = {
29
+ ValidationError: 2,
30
+ AuthenticationError: 3,
31
+ PlanAccessError: 3,
32
+ RateLimitError: 4,
33
+ APIConnectionError: 5,
34
+ NotFoundError: 6,
35
+ }
36
+ DEFAULT_EXIT_CODE = 1
37
+
38
+
39
+ class _JobStatusLike(Protocol):
40
+ results: Any
41
+
42
+ @property
43
+ def progress(self) -> int | None: ...
44
+
45
+ def to_dict(self) -> dict[str, Any]: ...
46
+
47
+
48
+ class SupportsWait(Protocol):
49
+ task_id: str
50
+ task_family: str
51
+
52
+ def wait(self, *, timeout: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ...
53
+
54
+
55
+ def _normalize(data: Any) -> Any: # noqa: ANN401 -- accepts arbitrary JSON-serializable CLI output data
56
+ if isinstance(data, DiscolikeModel):
57
+ return data.to_dict()
58
+ if isinstance(data, list):
59
+ return [item.to_dict() if isinstance(item, DiscolikeModel) else item for item in data]
60
+ return data
61
+
62
+
63
+ def _is_flat_dict(value: Any) -> bool: # noqa: ANN401 -- runtime-narrowed via isinstance, not a fixed shape
64
+ return isinstance(value, dict) and not any(isinstance(v, (dict, list)) for v in value.values())
65
+
66
+
67
+ def _qualifies_for_table(data: Any) -> bool: # noqa: ANN401 -- runtime-narrowed via isinstance, not a fixed shape
68
+ return isinstance(data, list) and len(data) > 0 and all(_is_flat_dict(item) for item in data)
69
+
70
+
71
+ def _render_table(rows: list[dict[str, Any]]) -> None:
72
+ table = Table()
73
+ columns = list(rows[0].keys())
74
+ for column in columns:
75
+ table.add_column(column)
76
+ for row in rows:
77
+ table.add_row(*(str(row.get(column, "")) for column in columns))
78
+ Console().print(table)
79
+
80
+
81
+ def emit(data: Any, *, fmt: str | None = None) -> None: # noqa: ANN401 -- accepts arbitrary JSON-serializable CLI output data
82
+ normalized = _normalize(data)
83
+ want_table = fmt == "table" or (fmt is None and sys.stdout.isatty())
84
+ if want_table and _qualifies_for_table(normalized):
85
+ _render_table(normalized)
86
+ return
87
+ print(json.dumps(normalized, indent=2, default=str))
88
+
89
+
90
+ def fail(exc: DiscolikeError) -> typer.Exit:
91
+ payload: dict[str, Any] = {
92
+ "error": type(exc).__name__,
93
+ "message": str(exc),
94
+ "status_code": exc.status_code,
95
+ }
96
+ if isinstance(exc, RateLimitError) and exc.retry_after is not None:
97
+ payload["retry_after"] = exc.retry_after
98
+ print(json.dumps(payload), file=sys.stderr)
99
+ return typer.Exit(code=EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE))
100
+
101
+
102
+ def call_typed(fn: Callable[..., R], **kwargs: Any) -> R: # noqa: ANN401 -- forwarded to a typed SDK method signature
103
+ try:
104
+ return fn(**kwargs)
105
+ except TypeError as exc:
106
+ raise typer.BadParameter(str(exc)) from exc
107
+
108
+
109
+ def handle_errors(fn: Callable[P, R]) -> Callable[P, R]:
110
+ @functools.wraps(fn)
111
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
112
+ try:
113
+ return fn(*args, **kwargs)
114
+ except DiscolikeError as exc:
115
+ raise fail(exc) from exc
116
+
117
+ return wrapper
118
+
119
+
120
+ def run_job(job: SupportsWait, *, wait: bool, timeout: float) -> None:
121
+ if not wait:
122
+ emit(
123
+ {
124
+ "task_id": job.task_id,
125
+ "task_family": job.task_family,
126
+ "hint": f"poll with: discolike discogen status {job.task_id} --family {job.task_family}",
127
+ }
128
+ )
129
+ return
130
+
131
+ def _on_poll(status: _JobStatusLike) -> None:
132
+ sys.stderr.write(f"progress: {status.progress}%\n")
133
+
134
+ final = job.wait(timeout=timeout, on_poll=_on_poll)
135
+ emit(final.results if final.results is not None else final.to_dict())
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from discolike_cli._output import emit
6
+ from discolike_cli._output import handle_errors
7
+
8
+ app = typer.Typer(help="Account usage and billing")
9
+
10
+
11
+ @app.command("usage")
12
+ @handle_errors
13
+ def usage_command(ctx: typer.Context) -> None:
14
+ from discolike_cli.main import get_client
15
+
16
+ emit(get_client(ctx).account.usage())
discolike_cli/auth.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ import typer
8
+
9
+ from discolike._config import ENV_API_KEY
10
+ from discolike._config import KEYS_URL
11
+ from discolike._config import delete_config
12
+ from discolike._config import load_config
13
+ from discolike._config import resolve_api_key
14
+ from discolike._config import save_config
15
+ from discolike_cli._output import emit
16
+ from discolike_cli._output import handle_errors
17
+
18
+ app = typer.Typer(help="Manage API credentials")
19
+
20
+ MASKED_VISIBLE_CHARS = 4
21
+
22
+
23
+ def _mask(key: str) -> str:
24
+ return "…" + key[-MASKED_VISIBLE_CHARS:]
25
+
26
+
27
+ @app.command()
28
+ @handle_errors
29
+ def login(
30
+ api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}"),
31
+ ) -> None:
32
+ from discolike_cli.main import build_client
33
+
34
+ key = api_key or typer.prompt("API key", hide_input=True)
35
+ build_client(api_key=key).account.usage()
36
+ save_config({"auth_method": "api_key", "api_key": key})
37
+ print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr)
38
+
39
+
40
+ @app.command()
41
+ @handle_errors
42
+ def status() -> None:
43
+ from discolike_cli.main import build_client
44
+
45
+ key = os.environ.get(ENV_API_KEY)
46
+ source = "env"
47
+ if not key:
48
+ key = load_config().get("api_key")
49
+ source = "config"
50
+ if not key:
51
+ resolve_api_key(None)
52
+ build_client(api_key=key).account.usage()
53
+ emit({"source": source, "api_key": _mask(str(key)), "valid": True})
54
+
55
+
56
+ @app.command()
57
+ @handle_errors
58
+ def logout() -> None:
59
+ delete_config()
60
+ emit({"logged_out": True})
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from discolike_cli._output import emit
6
+ from discolike_cli._output import handle_errors
7
+
8
+ app = typer.Typer(help="Look up company data by domain")
9
+
10
+
11
+ @app.command()
12
+ @handle_errors
13
+ def data(ctx: typer.Context, domain: str) -> None:
14
+ from discolike_cli.main import get_client
15
+
16
+ emit(get_client(ctx).companies.data(domain=domain))
17
+
18
+
19
+ @app.command()
20
+ @handle_errors
21
+ def score(ctx: typer.Context, domain: str) -> None:
22
+ from discolike_cli.main import get_client
23
+
24
+ emit(get_client(ctx).companies.score(domain=domain))
25
+
26
+
27
+ @app.command()
28
+ @handle_errors
29
+ def growth(ctx: typer.Context, domain: str) -> None:
30
+ from discolike_cli.main import get_client
31
+
32
+ emit(get_client(ctx).companies.growth(domain=domain))
33
+
34
+
35
+ @app.command()
36
+ @handle_errors
37
+ def metrics(ctx: typer.Context, domain: str) -> None:
38
+ from discolike_cli.main import get_client
39
+
40
+ emit(get_client(ctx).companies.metrics(domain=domain))
41
+
42
+
43
+ @app.command()
44
+ @handle_errors
45
+ def history(ctx: typer.Context, domain: str, max_records: int | None = typer.Option(None, "--max-records")) -> None:
46
+ from discolike_cli.main import get_client
47
+
48
+ emit(get_client(ctx).companies.history(domain=domain, max_records=max_records))
49
+
50
+
51
+ @app.command()
52
+ @handle_errors
53
+ def redirects(ctx: typer.Context, domain: str, match: str | None = typer.Option(None, "--match")) -> None:
54
+ from discolike_cli.main import get_client
55
+
56
+ emit(get_client(ctx).companies.redirects(domain=domain, match=match))
57
+
58
+
59
+ @app.command()
60
+ @handle_errors
61
+ def vendors(ctx: typer.Context, domain: str, match: str | None = typer.Option(None, "--match")) -> None:
62
+ from discolike_cli.main import get_client
63
+
64
+ emit(get_client(ctx).companies.vendors(domain=domain, match=match))
65
+
66
+
67
+ @app.command()
68
+ @handle_errors
69
+ def subsidiaries(ctx: typer.Context, domain: str, match: str | None = typer.Option(None, "--match")) -> None:
70
+ from discolike_cli.main import get_client
71
+
72
+ emit(get_client(ctx).companies.subsidiaries(domain=domain, match=match))
73
+
74
+
75
+ @app.command(name="public-links")
76
+ @handle_errors
77
+ def public_links(ctx: typer.Context, domain: str, source: str = typer.Option(..., "--source")) -> None:
78
+ from discolike_cli.main import get_client
79
+
80
+ emit(get_client(ctx).companies.public_links(domain=domain, source=source))
81
+
82
+
83
+ @handle_errors
84
+ def extract_command(ctx: typer.Context, url: str) -> None:
85
+ from discolike_cli.main import get_client
86
+
87
+ emit(get_client(ctx).companies.extract(url=url))
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+
6
+ import typer
7
+
8
+ from discolike_cli._output import call_typed
9
+ from discolike_cli._output import emit
10
+ from discolike_cli._output import handle_errors
11
+ from discolike_cli._output import run_job
12
+ from discolike_cli.discover import _merge_params
13
+
14
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
15
+
16
+ app = typer.Typer(help="Search, match, and enrich contacts")
17
+
18
+
19
+ @app.command("search")
20
+ @handle_errors
21
+ def search_command(
22
+ ctx: typer.Context,
23
+ icp_prompt: str | None = typer.Option(None),
24
+ icp_text: str | None = typer.Option(None),
25
+ seniority: list[str] | None = typer.Option(None),
26
+ department: list[str] | None = typer.Option(None),
27
+ title: list[str] | None = typer.Option(None),
28
+ domain: list[str] | None = typer.Option(None),
29
+ person_country: list[str] | None = typer.Option(None),
30
+ filter_industry: list[str] | None = typer.Option(None),
31
+ filter_country: list[str] | None = typer.Option(None),
32
+ employee_range: str | None = typer.Option(None),
33
+ has_email: bool | None = typer.Option(None, "--has-email/--no-has-email"),
34
+ max_records: int | None = typer.Option(None),
35
+ offset: int | None = typer.Option(None),
36
+ fmt: str | None = typer.Option(None, "--format"),
37
+ param: list[str] | None = typer.Option(None, "--param"),
38
+ ) -> None:
39
+ from discolike_cli.main import get_client
40
+
41
+ kwargs = _merge_params(
42
+ param,
43
+ icp_prompt=icp_prompt,
44
+ icp_text=icp_text,
45
+ seniority=seniority,
46
+ department=department,
47
+ title=title,
48
+ domain=domain,
49
+ person_country=person_country,
50
+ filter_industry=filter_industry,
51
+ filter_country=filter_country,
52
+ employee_range=employee_range,
53
+ has_email=has_email,
54
+ max_records=max_records,
55
+ offset=offset,
56
+ )
57
+ emit(call_typed(get_client(ctx).contacts.search, **kwargs), fmt=fmt)
58
+
59
+
60
+ @app.command("count")
61
+ @handle_errors
62
+ def count_command(
63
+ ctx: typer.Context,
64
+ icp_prompt: str | None = typer.Option(None),
65
+ icp_text: str | None = typer.Option(None),
66
+ seniority: list[str] | None = typer.Option(None),
67
+ department: list[str] | None = typer.Option(None),
68
+ title: list[str] | None = typer.Option(None),
69
+ domain: list[str] | None = typer.Option(None),
70
+ person_country: list[str] | None = typer.Option(None),
71
+ filter_industry: list[str] | None = typer.Option(None),
72
+ filter_country: list[str] | None = typer.Option(None),
73
+ employee_range: str | None = typer.Option(None),
74
+ has_email: bool | None = typer.Option(None, "--has-email/--no-has-email"),
75
+ fmt: str | None = typer.Option(None, "--format"),
76
+ param: list[str] | None = typer.Option(None, "--param"),
77
+ ) -> None:
78
+ from discolike_cli.main import get_client
79
+
80
+ kwargs = _merge_params(
81
+ param,
82
+ icp_prompt=icp_prompt,
83
+ icp_text=icp_text,
84
+ seniority=seniority,
85
+ department=department,
86
+ title=title,
87
+ domain=domain,
88
+ person_country=person_country,
89
+ filter_industry=filter_industry,
90
+ filter_country=filter_country,
91
+ employee_range=employee_range,
92
+ has_email=has_email,
93
+ )
94
+ emit(call_typed(get_client(ctx).contacts.count, **kwargs), fmt=fmt)
95
+
96
+
97
+ @app.command("lookup")
98
+ @handle_errors
99
+ def lookup_command(
100
+ ctx: typer.Context,
101
+ persona_id: int | None = typer.Option(None, "--persona-id"),
102
+ linkedin: str | None = typer.Option(None, "--linkedin"),
103
+ email: str | None = typer.Option(None, "--email"),
104
+ ) -> None:
105
+ from discolike_cli.main import get_client
106
+
107
+ emit(get_client(ctx).contacts.lookup(persona_id=persona_id, linkedin=linkedin, email=email))
108
+
109
+
110
+ @app.command("match")
111
+ @handle_errors
112
+ def match_command(
113
+ ctx: typer.Context,
114
+ name: str = typer.Argument(...),
115
+ company_name: str | None = typer.Option(None, "--company-name"),
116
+ domain: str | None = typer.Option(None, "--domain"),
117
+ person_country: str | None = typer.Option(None, "--person-country"),
118
+ limit: int | None = typer.Option(None, "--limit"),
119
+ ) -> None:
120
+ from discolike_cli.main import get_client
121
+
122
+ emit(
123
+ get_client(ctx).contacts.match(
124
+ name=name,
125
+ company_name=company_name,
126
+ domain=domain,
127
+ person_country=person_country,
128
+ limit=limit,
129
+ )
130
+ )
131
+
132
+
133
+ @app.command("bulk-match")
134
+ @handle_errors
135
+ def bulk_match_command(
136
+ ctx: typer.Context,
137
+ queries_file: pathlib.Path = typer.Option(..., "--queries-file"),
138
+ enrich: bool | None = typer.Option(None, "--enrich/--no-enrich"),
139
+ limit: int | None = typer.Option(None, "--limit"),
140
+ wait: bool = typer.Option(False, "--wait"),
141
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
142
+ ) -> None:
143
+ from discolike_cli.main import get_client
144
+
145
+ try:
146
+ queries = json.loads(queries_file.read_text())
147
+ except json.JSONDecodeError as exc:
148
+ raise typer.BadParameter(f"--queries-file must contain valid JSON: {exc}") from exc
149
+ if not isinstance(queries, list):
150
+ raise typer.BadParameter("--queries-file must contain a JSON array of objects")
151
+
152
+ job = get_client(ctx).contacts.bulk_match(queries=queries, enrich=enrich, limit=limit)
153
+ run_job(job, wait=wait, timeout=timeout)
154
+
155
+
156
+ @app.command("discover")
157
+ @handle_errors
158
+ def discover_command(
159
+ ctx: typer.Context,
160
+ icp_prompt: str | None = typer.Option(None),
161
+ icp_text: str | None = typer.Option(None),
162
+ seniority: list[str] | None = typer.Option(None),
163
+ department: list[str] | None = typer.Option(None),
164
+ title: list[str] | None = typer.Option(None),
165
+ domain: list[str] | None = typer.Option(None),
166
+ person_country: list[str] | None = typer.Option(None),
167
+ filter_industry: list[str] | None = typer.Option(None),
168
+ filter_country: list[str] | None = typer.Option(None),
169
+ employee_range: str | None = typer.Option(None),
170
+ has_email: bool | None = typer.Option(None, "--has-email/--no-has-email"),
171
+ max_records: int | None = typer.Option(None),
172
+ offset: int | None = typer.Option(None),
173
+ results_by_company: int | None = typer.Option(None, "--results-by-company"),
174
+ include_search_contacts: bool | None = typer.Option(None, "--include-search-contacts/--no-include-search-contacts"),
175
+ consensus: int | None = typer.Option(None, "--consensus"),
176
+ fmt: str | None = typer.Option(None, "--format"),
177
+ param: list[str] | None = typer.Option(None, "--param"),
178
+ ) -> None:
179
+ from discolike_cli.main import get_client
180
+
181
+ kwargs = _merge_params(
182
+ param,
183
+ icp_prompt=icp_prompt,
184
+ icp_text=icp_text,
185
+ seniority=seniority,
186
+ department=department,
187
+ title=title,
188
+ domain=domain,
189
+ person_country=person_country,
190
+ filter_industry=filter_industry,
191
+ filter_country=filter_country,
192
+ employee_range=employee_range,
193
+ has_email=has_email,
194
+ max_records=max_records,
195
+ offset=offset,
196
+ results_by_company=results_by_company,
197
+ include_search_contacts=include_search_contacts,
198
+ consensus=consensus,
199
+ )
200
+ emit(call_typed(get_client(ctx).contacts.discover, **kwargs), fmt=fmt)
201
+
202
+
203
+ @app.command("generate")
204
+ @handle_errors
205
+ def generate_command(
206
+ ctx: typer.Context,
207
+ icp_text: str = typer.Option(..., "--icp-text"),
208
+ domain: list[str] = typer.Option(..., "--domain"),
209
+ context_mode: str | None = typer.Option(None, "--context-mode"),
210
+ integration_id: str | None = typer.Option(None, "--integration-id"),
211
+ search_provider_id: str | None = typer.Option(None, "--search-provider-id"),
212
+ search_context_size: str | None = typer.Option(None, "--search-context-size"),
213
+ max_contacts_per_domain: int | None = typer.Option(None, "--max-contacts-per-domain"),
214
+ max_company_records: int | None = typer.Option(None, "--max-company-records"),
215
+ wait: bool = typer.Option(False, "--wait"),
216
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
217
+ ) -> None:
218
+ from discolike_cli.main import get_client
219
+
220
+ job = get_client(ctx).contacts.generate(
221
+ icp_text=icp_text,
222
+ domains=domain,
223
+ context_mode=context_mode,
224
+ integration_id=integration_id,
225
+ search_provider_id=search_provider_id,
226
+ search_context_size=search_context_size,
227
+ max_contacts_per_domain=max_contacts_per_domain,
228
+ max_company_records=max_company_records,
229
+ )
230
+ run_job(job, wait=wait, timeout=timeout)
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+
5
+ import typer
6
+
7
+ from discolike._jobs import Job
8
+ from discolike_cli._output import emit
9
+ from discolike_cli._output import handle_errors
10
+ from discolike_cli._output import run_job
11
+
12
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
13
+
14
+ app = typer.Typer(help="Run DiscoGen research and manage async tasks")
15
+
16
+
17
+ class TaskFamily(str, enum.Enum):
18
+ discogen = "discogen"
19
+ bulkmatch = "bulkmatch"
20
+ contactmatch = "contactmatch"
21
+ segment = "segment"
22
+
23
+
24
+ @app.command("run")
25
+ @handle_errors
26
+ def run_command(
27
+ ctx: typer.Context,
28
+ query: str = typer.Option(..., "--query"),
29
+ domain: list[str] = typer.Option(..., "--domain"),
30
+ integration_id: str | None = typer.Option(None, "--integration-id"),
31
+ web_search: bool | None = typer.Option(None, "--web-search/--no-web-search"),
32
+ context_mode: str | None = typer.Option(None, "--context-mode"),
33
+ include_x_search: bool | None = typer.Option(None, "--include-x-search/--no-include-x-search"),
34
+ search_provider_id: str | None = typer.Option(None, "--search-provider-id"),
35
+ search_context_size: str | None = typer.Option(None, "--search-context-size"),
36
+ wait: bool = typer.Option(False, "--wait"),
37
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
38
+ ) -> None:
39
+ from discolike_cli.main import get_client
40
+
41
+ job = get_client(ctx).discogen.process(
42
+ query=query,
43
+ domains=domain,
44
+ integration_id=integration_id,
45
+ web_search=web_search,
46
+ context_mode=context_mode,
47
+ include_x_search=include_x_search,
48
+ search_provider_id=search_provider_id,
49
+ search_context_size=search_context_size,
50
+ )
51
+ run_job(job, wait=wait, timeout=timeout)
52
+
53
+
54
+ @app.command("run-personas")
55
+ @handle_errors
56
+ def run_personas_command(
57
+ ctx: typer.Context,
58
+ query: str = typer.Option(..., "--query"),
59
+ persona_id: list[int] = typer.Option(..., "--persona-id"),
60
+ integration_id: str | None = typer.Option(None, "--integration-id"),
61
+ web_search: bool | None = typer.Option(None, "--web-search/--no-web-search"),
62
+ context_mode: str | None = typer.Option(None, "--context-mode"),
63
+ include_x_search: bool | None = typer.Option(None, "--include-x-search/--no-include-x-search"),
64
+ search_provider_id: str | None = typer.Option(None, "--search-provider-id"),
65
+ search_context_size: str | None = typer.Option(None, "--search-context-size"),
66
+ wait: bool = typer.Option(False, "--wait"),
67
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
68
+ ) -> None:
69
+ from discolike_cli.main import get_client
70
+
71
+ job = get_client(ctx).discogen.process_personas(
72
+ query=query,
73
+ persona_ids=persona_id,
74
+ integration_id=integration_id,
75
+ web_search=web_search,
76
+ context_mode=context_mode,
77
+ include_x_search=include_x_search,
78
+ search_provider_id=search_provider_id,
79
+ search_context_size=search_context_size,
80
+ )
81
+ run_job(job, wait=wait, timeout=timeout)
82
+
83
+
84
+ @app.command("models")
85
+ @handle_errors
86
+ def models_command(ctx: typer.Context) -> None:
87
+ from discolike_cli.main import get_client
88
+
89
+ emit(get_client(ctx).discogen.models())
90
+
91
+
92
+ def _build_job(ctx: typer.Context, family: TaskFamily, task_id: str) -> Job:
93
+ from discolike_cli.main import get_client
94
+
95
+ client = get_client(ctx)
96
+ if family is TaskFamily.discogen:
97
+ return client.discogen.job(task_id)
98
+ return Job(client._transport, task_family=family.value, task_id=task_id)
99
+
100
+
101
+ @app.command("status")
102
+ @handle_errors
103
+ def status_command(
104
+ ctx: typer.Context,
105
+ task_id: str = typer.Argument(...),
106
+ family: TaskFamily = typer.Option(TaskFamily.discogen, "--family"),
107
+ ) -> None:
108
+ emit(_build_job(ctx, family, task_id).status())
109
+
110
+
111
+ @app.command("cancel")
112
+ @handle_errors
113
+ def cancel_command(
114
+ ctx: typer.Context,
115
+ task_id: str = typer.Argument(...),
116
+ family: TaskFamily = typer.Option(TaskFamily.discogen, "--family"),
117
+ ) -> None:
118
+ _build_job(ctx, family, task_id).cancel()
119
+ emit({"cancelled": task_id})
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from discolike_cli._output import call_typed
8
+ from discolike_cli._output import emit
9
+ from discolike_cli._output import handle_errors
10
+
11
+ PARAM_SEPARATOR = "="
12
+ LIST_VALUE_SEPARATOR = ","
13
+
14
+
15
+ def _parse_param(raw: str) -> tuple[str, str | list[str]]:
16
+ if PARAM_SEPARATOR not in raw:
17
+ raise typer.BadParameter(f"--param must be in KEY=VALUE form, got {raw!r}")
18
+ key, _, value = raw.partition(PARAM_SEPARATOR)
19
+ if LIST_VALUE_SEPARATOR in value:
20
+ return key, value.split(LIST_VALUE_SEPARATOR)
21
+ return key, value
22
+
23
+
24
+ def _merge_params(param: list[str] | None, **options: Any) -> dict[str, Any]: # noqa: ANN401 -- forwarded as **kwargs to typed resource/client methods
25
+ kwargs: dict[str, Any] = dict(_parse_param(raw) for raw in param or [])
26
+ kwargs.update({key: value for key, value in options.items() if value is not None})
27
+ return kwargs
28
+
29
+
30
+ @handle_errors
31
+ def discover_command(
32
+ ctx: typer.Context,
33
+ icp_prompt: str | None = typer.Option(None),
34
+ icp_text: str | None = typer.Option(None),
35
+ domain: list[str] | None = typer.Option(None),
36
+ phrase_match: list[str] | None = typer.Option(None),
37
+ category: list[str] | None = typer.Option(None),
38
+ country: list[str] | None = typer.Option(None),
39
+ state: list[str] | None = typer.Option(None),
40
+ employee_range: str | None = typer.Option(None),
41
+ revenue_range: str | None = typer.Option(None),
42
+ business_model: list[str] | None = typer.Option(None),
43
+ tech_stack: list[str] | None = typer.Option(None),
44
+ min_digital_footprint: int | None = typer.Option(None),
45
+ max_digital_footprint: int | None = typer.Option(None),
46
+ exclude_domain: list[str] | None = typer.Option(None),
47
+ exclusion_query_id: list[str] | None = typer.Option(None),
48
+ max_records: int | None = typer.Option(None),
49
+ offset: int | None = typer.Option(None),
50
+ fmt: str | None = typer.Option(None, "--format"),
51
+ param: list[str] | None = typer.Option(None, "--param"),
52
+ ) -> None:
53
+ from discolike_cli.main import get_client
54
+
55
+ kwargs = _merge_params(
56
+ param,
57
+ icp_prompt=icp_prompt,
58
+ icp_text=icp_text,
59
+ domain=domain,
60
+ phrase_match=phrase_match,
61
+ category=category,
62
+ country=country,
63
+ state=state,
64
+ employee_range=employee_range,
65
+ revenue_range=revenue_range,
66
+ business_model=business_model,
67
+ tech_stack=tech_stack,
68
+ min_digital_footprint=min_digital_footprint,
69
+ max_digital_footprint=max_digital_footprint,
70
+ exclude_domain=exclude_domain,
71
+ exclusion_query_id=exclusion_query_id,
72
+ max_records=max_records,
73
+ offset=offset,
74
+ )
75
+ companies = call_typed(get_client(ctx).discover, **kwargs)
76
+ emit(companies, fmt=fmt)
77
+
78
+
79
+ @handle_errors
80
+ def count_command(
81
+ ctx: typer.Context,
82
+ phrase_match: list[str] | None = typer.Option(None),
83
+ category: list[str] | None = typer.Option(None),
84
+ country: list[str] | None = typer.Option(None),
85
+ state: list[str] | None = typer.Option(None),
86
+ employee_range: str | None = typer.Option(None),
87
+ revenue_range: str | None = typer.Option(None),
88
+ business_model: list[str] | None = typer.Option(None),
89
+ tech_stack: list[str] | None = typer.Option(None),
90
+ min_digital_footprint: int | None = typer.Option(None),
91
+ max_digital_footprint: int | None = typer.Option(None),
92
+ fmt: str | None = typer.Option(None, "--format"),
93
+ param: list[str] | None = typer.Option(None, "--param"),
94
+ ) -> None:
95
+ from discolike_cli.main import get_client
96
+
97
+ kwargs = _merge_params(
98
+ param,
99
+ phrase_match=phrase_match,
100
+ category=category,
101
+ country=country,
102
+ state=state,
103
+ employee_range=employee_range,
104
+ revenue_range=revenue_range,
105
+ business_model=business_model,
106
+ tech_stack=tech_stack,
107
+ min_digital_footprint=min_digital_footprint,
108
+ max_digital_footprint=max_digital_footprint,
109
+ )
110
+ count = call_typed(get_client(ctx).count, **kwargs)
111
+ emit(count, fmt=fmt)
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ import typer
6
+
7
+ from discolike_cli._output import emit
8
+ from discolike_cli._output import handle_errors
9
+ from discolike_cli._output import run_job
10
+
11
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
12
+
13
+
14
+ @handle_errors
15
+ def validate_icp_command(
16
+ ctx: typer.Context,
17
+ icp: str = typer.Option(..., "--icp"),
18
+ domain: list[str] | None = typer.Option(None, "--domain"),
19
+ file: pathlib.Path | None = typer.Option(None, "--file"),
20
+ context_mode: str | None = typer.Option(None, "--context-mode"),
21
+ integration_id: str | None = typer.Option(None, "--integration-id"),
22
+ web_search: bool | None = typer.Option(None, "--web-search/--no-web-search"),
23
+ search_provider_id: str | None = typer.Option(None, "--search-provider-id"),
24
+ wait: bool = typer.Option(False, "--wait"),
25
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
26
+ ) -> None:
27
+ from discolike_cli.main import get_client
28
+
29
+ if (not domain) == (file is None):
30
+ raise typer.BadParameter("Provide exactly one of --domain or --file")
31
+
32
+ if file is not None:
33
+ domains = [line.strip() for line in file.read_text().splitlines() if line.strip()]
34
+ else:
35
+ assert domain is not None
36
+ domains = domain
37
+
38
+ job = get_client(ctx).validate_icp(
39
+ icp_text=icp,
40
+ domains=domains,
41
+ context_mode=context_mode,
42
+ integration_id=integration_id,
43
+ web_search=web_search,
44
+ search_provider_id=search_provider_id,
45
+ )
46
+ run_job(job, wait=wait, timeout=timeout)
47
+
48
+
49
+ @handle_errors
50
+ def append_command(
51
+ ctx: typer.Context,
52
+ file: pathlib.Path = typer.Argument(...),
53
+ dataset: list[str] | None = typer.Option(None, "--dataset"),
54
+ domain_column: str | None = typer.Option(None, "--domain-column"),
55
+ csv: bool | None = typer.Option(None, "--csv/--no-csv"),
56
+ output: pathlib.Path | None = typer.Option(None, "--output"),
57
+ ) -> None:
58
+ from discolike_cli.main import get_client
59
+
60
+ result = get_client(ctx).append(file=file, dataset=dataset, domain_column=domain_column, csv=csv)
61
+ if isinstance(result, bytes):
62
+ if output is None:
63
+ raise typer.BadParameter("--output is required when the response is CSV bytes")
64
+ output.write_bytes(result)
65
+ emit({"written": str(output), "bytes": len(result)})
66
+ return
67
+ emit(result)
68
+
69
+
70
+ @handle_errors
71
+ def segment_command(
72
+ ctx: typer.Context,
73
+ domain: list[str] | None = typer.Option(None, "--domain"),
74
+ file: pathlib.Path | None = typer.Option(None, "--file"),
75
+ domain_column: str | None = typer.Option(None, "--domain-column"),
76
+ max_segments: int | None = typer.Option(None, "--max-segments"),
77
+ wait: bool = typer.Option(False, "--wait"),
78
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
79
+ ) -> None:
80
+ from discolike_cli.main import get_client
81
+
82
+ if (not domain) == (file is None):
83
+ raise typer.BadParameter("Provide exactly one of --domain or --file")
84
+
85
+ job = get_client(ctx).segment(
86
+ domains=domain or None,
87
+ file=file,
88
+ domain_column=domain_column,
89
+ max_segments=max_segments,
90
+ )
91
+ run_job(job, wait=wait, timeout=timeout)
discolike_cli/main.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version as package_version
4
+ from typing import Any
5
+
6
+ import typer
7
+
8
+ from discolike import Discolike
9
+ from discolike import __version__ as sdk_version
10
+ from discolike_cli import account
11
+ from discolike_cli import auth
12
+ from discolike_cli import company
13
+ from discolike_cli import contacts
14
+ from discolike_cli import discogen
15
+ from discolike_cli import discover
16
+ from discolike_cli import enrich
17
+ from discolike_cli import match
18
+ from discolike_cli import queries
19
+
20
+ app = typer.Typer(
21
+ name="discolike",
22
+ no_args_is_help=True,
23
+ help="DiscoLike API from your terminal — https://docs.discolike.com",
24
+ )
25
+
26
+ build_client = Discolike
27
+
28
+
29
+ @app.callback(invoke_without_command=True)
30
+ def main(
31
+ ctx: typer.Context,
32
+ api_key: str | None = typer.Option(None, envvar="DISCOLIKE_API_KEY"),
33
+ base_url: str | None = typer.Option(None),
34
+ version: bool = typer.Option(False, "--version"),
35
+ ) -> None:
36
+ if version:
37
+ typer.echo(f"discolike-cli {package_version('discolike-cli')} (discolike {sdk_version})")
38
+ raise typer.Exit
39
+ ctx.obj = {"api_key": api_key, "base_url": base_url}
40
+
41
+
42
+ def get_client(ctx: typer.Context) -> Discolike:
43
+ kwargs: dict[str, Any] = {k: v for k, v in ctx.obj.items() if v is not None}
44
+ return build_client(**kwargs)
45
+
46
+
47
+ app.add_typer(auth.app, name="auth")
48
+ app.add_typer(company.app, name="company")
49
+ app.add_typer(contacts.app, name="contacts")
50
+ app.add_typer(discogen.app, name="discogen")
51
+ app.add_typer(queries.app, name="queries")
52
+ app.add_typer(account.app, name="account")
53
+ app.command(name="discover")(discover.discover_command)
54
+ app.command(name="count")(discover.count_command)
55
+ app.command(name="match")(match.match_command)
56
+ app.command(name="extract")(company.extract_command)
57
+ app.command(name="validate-icp")(enrich.validate_icp_command)
58
+ app.command(name="append")(enrich.append_command)
59
+ app.command(name="segment")(enrich.segment_command)
discolike_cli/match.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+
5
+ import typer
6
+
7
+ from discolike_cli._output import emit
8
+ from discolike_cli._output import handle_errors
9
+ from discolike_cli._output import run_job
10
+
11
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
12
+ DEFAULT_NAME_COLUMN = "name"
13
+
14
+
15
+ @handle_errors
16
+ def match_command(
17
+ ctx: typer.Context,
18
+ name: str | None = typer.Argument(None),
19
+ phone: str | None = typer.Option(None),
20
+ city: str | None = typer.Option(None),
21
+ state: str | None = typer.Option(None),
22
+ country: str | None = typer.Option(None),
23
+ zip_code: str | None = typer.Option(None),
24
+ strict: bool | None = typer.Option(None, "--strict/--no-strict"),
25
+ local_mode: bool | None = typer.Option(None, "--local-mode/--no-local-mode"),
26
+ file: pathlib.Path | None = typer.Option(None, "--file"),
27
+ name_column: str = typer.Option(DEFAULT_NAME_COLUMN, "--name-column"),
28
+ wait: bool = typer.Option(False, "--wait"),
29
+ timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout"),
30
+ fmt: str | None = typer.Option(None, "--format"),
31
+ ) -> None:
32
+ from discolike_cli.main import get_client
33
+
34
+ if (name is None) == (file is None):
35
+ raise typer.BadParameter("Provide exactly one of NAME or --file")
36
+
37
+ client = get_client(ctx)
38
+ if name is not None:
39
+ response = client.match.company(
40
+ name=name,
41
+ phone=phone,
42
+ city=city,
43
+ state=state,
44
+ country=country,
45
+ zip_code=zip_code,
46
+ strict=strict,
47
+ local_mode=local_mode,
48
+ )
49
+ emit(response, fmt=fmt)
50
+ return
51
+
52
+ assert file is not None
53
+ job = client.match.bulk(file=file, name_column=name_column, strict=strict, local_mode=local_mode)
54
+ run_job(job, wait=wait, timeout=timeout)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from discolike_cli._output import emit
6
+ from discolike_cli._output import handle_errors
7
+
8
+ app = typer.Typer(help="Manage saved queries and exclusion lists")
9
+
10
+
11
+ @app.command("list")
12
+ @handle_errors
13
+ def list_command(
14
+ ctx: typer.Context,
15
+ max_records: int | None = typer.Option(None, "--max-records"),
16
+ offset: int | None = typer.Option(None, "--offset"),
17
+ action: str | None = typer.Option(None, "--action"),
18
+ tag: list[str] | None = typer.Option(None, "--tag"),
19
+ ) -> None:
20
+ from discolike_cli.main import get_client
21
+
22
+ emit(get_client(ctx).queries.list(max_records=max_records, offset=offset, action=action, tags=tag))
23
+
24
+
25
+ @app.command("create-exclusion-list")
26
+ @handle_errors
27
+ def create_exclusion_list_command(
28
+ ctx: typer.Context,
29
+ name: str = typer.Option(..., "--name"),
30
+ domain: list[str] | None = typer.Option(None, "--domain"),
31
+ persona_id: list[int] | None = typer.Option(None, "--persona-id"),
32
+ tag: list[str] | None = typer.Option(None, "--tag"),
33
+ ) -> None:
34
+ from discolike_cli.main import get_client
35
+
36
+ emit(
37
+ get_client(ctx).queries.create_exclusion_list(
38
+ query_name=name,
39
+ domains=domain,
40
+ persona_ids=persona_id,
41
+ tags=tag,
42
+ )
43
+ )
44
+
45
+
46
+ @app.command("update")
47
+ @handle_errors
48
+ def update_command(
49
+ ctx: typer.Context,
50
+ query_id: str = typer.Argument(...),
51
+ name: str | None = typer.Option(None, "--name"),
52
+ tag: list[str] | None = typer.Option(None, "--tag"),
53
+ ) -> None:
54
+ from discolike_cli.main import get_client
55
+
56
+ emit(get_client(ctx).queries.update(query_id=query_id, query_name=name, tags=tag))
57
+
58
+
59
+ @app.command("delete")
60
+ @handle_errors
61
+ def delete_command(ctx: typer.Context, query_id: str = typer.Argument(...)) -> None:
62
+ from discolike_cli.main import get_client
63
+
64
+ get_client(ctx).queries.delete(query_id=query_id)
65
+ emit({"deleted": query_id})
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: discolike-cli
3
+ Version: 0.1.0
4
+ Summary: Official CLI for the DiscoLike API
5
+ Project-URL: Homepage, https://www.discolike.com
6
+ Project-URL: Documentation, https://docs.discolike.com
7
+ Project-URL: Repository, https://github.com/Discolike/discolike-python
8
+ Author-email: DiscoLike <support@discolike.com>
9
+ License-Expression: MIT
10
+ Keywords: business-data,cli,discolike,enrichment
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: discolike>=0.1.0
21
+ Requires-Dist: rich>=13.0
22
+ Requires-Dist: typer>=0.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # discolike-cli
26
+
27
+ Official CLI for the [DiscoLike API](https://www.discolike.com) — the `discolike` command from your terminal. Built on the [`discolike`](https://pypi.org/project/discolike/) SDK.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install discolike-cli
33
+ ```
34
+
35
+ Or run it without installing:
36
+
37
+ ```bash
38
+ uvx --from discolike-cli discolike --help
39
+ ```
40
+
41
+ Requires Python 3.10+. Installing this package gives you the `discolike` command.
42
+
43
+ ## Authentication
44
+
45
+ ```bash
46
+ discolike auth login
47
+ ```
48
+
49
+ Prompts for an API key (or pass `--api-key`) and verifies it against your account. Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead.
50
+
51
+ ## Quickstart
52
+
53
+ ```bash
54
+ discolike discover --icp-text "managed IT services for SMBs" --country US --max-records 25
55
+ discolike match "Stripe Inc" --city "San Francisco"
56
+ discolike match --file companies.csv --name-column company_name --wait
57
+ discolike count --phrase-match "book a demo" --country US
58
+ discolike company data stripe.com
59
+ discolike extract https://stripe.com/enterprise
60
+ ```
61
+
62
+ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, and `account` command groups.
63
+
64
+ ### Conventions
65
+
66
+ - Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr.
67
+ - Pass `--format table` for a human-readable table — used automatically when stdout is a TTY.
68
+ - 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`).
69
+
70
+ ### Exit codes
71
+
72
+ | Exit code | Meaning |
73
+ |---|---|
74
+ | 0 | Success |
75
+ | 1 | Server error or unexpected failure |
76
+ | 2 | Validation error |
77
+ | 3 | Authentication or plan-access error |
78
+ | 4 | Rate limited |
79
+ | 5 | Network error |
80
+ | 6 | Not found |
81
+
82
+ ## Links
83
+
84
+ - **API documentation**: [docs.discolike.com](https://docs.discolike.com)
85
+ - **Source**: [github.com/Discolike/discolike-python](https://github.com/Discolike/discolike-python)
86
+
87
+ ## License
88
+
89
+ [MIT](https://github.com/Discolike/discolike-python/blob/main/LICENSE)
@@ -0,0 +1,16 @@
1
+ discolike_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ discolike_cli/_output.py,sha256=e1hjf1RTNxiXFJzUd0-3iEk8objeqTX59SgKsUQyfx4,4270
3
+ discolike_cli/account.py,sha256=l6Xnx4D77CsiamTNyhfpJmxErU_TGD8rh-uoJdJ7J30,365
4
+ discolike_cli/auth.py,sha256=Lm099QKljDgOM3EEh29yd7RYZzikXN_G5lAJbriEjKc,1534
5
+ discolike_cli/company.py,sha256=ctE-zqQUAinS4VLqxVm_-WszhPWOZZgMJu94Q-8ldeU,2470
6
+ discolike_cli/contacts.py,sha256=HJafEy3hkWdLuPYfivsZFKfyF35hoBWnY3spmb6WtbQ,8423
7
+ discolike_cli/discogen.py,sha256=3a9MzP_ev1xsYhbVtUQF8GX5fK06QW_WXRiMMYDX_8I,4035
8
+ discolike_cli/discover.py,sha256=HbW_sUIn54QnThoM-4OW-gjKZQn8p6bFNUj_WFfuzHU,4085
9
+ discolike_cli/enrich.py,sha256=tlxlvRNkMnPP6WAnzA9ZfPyeRs6vYivJRr3SGifgLQQ,3200
10
+ discolike_cli/main.py,sha256=yomidRmXoGsXa6MsQ-H9tQyNud0YFPttFi8qDLqhZTM,1943
11
+ discolike_cli/match.py,sha256=MeIiiNyHMMB94763s9NaQqcyUVl9vBT38J9E7uSw1V8,1780
12
+ discolike_cli/queries.py,sha256=WHuhihvRs6SS1HFMe3GOYMJ2wdtf17NsLgIDtIOryBY,1927
13
+ discolike_cli-0.1.0.dist-info/METADATA,sha256=k_Uijf6u-kvxXdd5k8gfU2prR4X9804Uo0u4Hu4x9oU,3330
14
+ discolike_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
+ discolike_cli-0.1.0.dist-info/entry_points.txt,sha256=j3pX_qdZxguS3cAlIQwl8QYNyG9-eOWg4R1aOpk6zis,53
16
+ discolike_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ discolike = discolike_cli.main:app