Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.3.1 (2026-09-02)

- SDK: `discolike.signup()` / `discolike.async_signup()` create a DiscoLike account for a person from their work email and name, with no credential required. Returns `SignupResult` with the `next_step` text to relay.
- CLI: `discolike signup --email --first-name --last-name` does the same from the terminal, without `discolike auth login`. The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.
- SDK: `signup()` / `async_signup()` always post to `base_url` (the DiscoLike API by default); an injected `http_client=` is used as transport only, and its own base URL never redirects the signup request.
- SDK: `AppendParams.dataset` accepts the new `subdomains` dataset — appends the subdomains observed for each domain (up to 300, most popular first).
- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.

## 0.3.0 (2026-08-29)

- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape.
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<a href="https://discolike.com">Website</a> ·
<a href="https://docs.discolike.com">API Docs</a> ·
<a href="https://app.discolike.com/account/management/keys">Get an API key</a> ·
<a href="https://auth.discolike.com/en/signup">Sign up</a> ·
<a href="https://discolike.com/signup">Sign up</a> ·
<a href="https://calendly.com/discolike/introductory-call">Book a demo</a> ·
<a href="https://discolike.com/blog/">Blog</a>
</p>
Expand Down Expand Up @@ -67,6 +67,14 @@ Requires Python 3.10+.

## Authentication

No account yet? An agent (or you) can open one without a browser; the account owner confirms by email and logs in:

```bash
discolike signup --email jane@acme.com --first-name Jane --last-name Doe
```

The CLI remembers the last email signed up from this machine and asks before signing up a different one (`--yes` skips the prompt). `discolike auth login` asks first whether you already have an account and offers signup if not.

Create an API key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys), then use any of:

```bash
Expand Down Expand Up @@ -175,9 +183,10 @@ discolike match --file companies.csv --name-column company_name --wait
discolike count --phrase-match "book a demo" --country US
discolike company data stripe.com
discolike extract https://stripe.com/enterprise
discolike signup --email you@company.com --first-name You --last-name Person
```

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.
Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment`, `signup` — plus `auth`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.

### CLI conventions

Expand Down Expand Up @@ -288,7 +297,7 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli
## Support & contact

- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
- **Sign up**: [auth.discolike.com/en/signup](https://auth.discolike.com/en/signup)
- **Sign up**: [discolike.com/signup](https://discolike.com/signup)
- **Book a demo**: [calendly.com/discolike/introductory-call](https://calendly.com/discolike/introductory-call)
- **LinkedIn**: [linkedin.com/company/discolike](https://www.linkedin.com/company/discolike/)
- **Issues with this SDK**: [GitHub issues](https://github.com/Discolike/discolike-python/issues)
Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ build-backend = "hatchling.build"

[project]
name = "discolike-cli"
version = "0.3.0"
version = "0.3.1"
description = "Official CLI for the DiscoLike API"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
"discolike==0.3.0",
"discolike==0.3.1",
"typer>=0.12",
"rich>=13.0",
]
Expand Down
37 changes: 32 additions & 5 deletions packages/discolike-cli/src/discolike_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
from discolike._oauth import build_authorization_url
from discolike._oauth import discover
from discolike._oauth import exchange_code
from discolike._oauth import pkce_pair
from discolike._oauth import register_client
from discolike_cli._loopback import CallbackServer
from discolike_cli._output import emit
from discolike_cli._output import handle_errors
from discolike_cli.signup import _is_interactive
from discolike_cli.signup import run_signup

app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")

Expand All @@ -55,6 +56,10 @@
DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})


HAS_ACCOUNT_PROMPT = "Do you already have a DiscoLike account?"
SIGNUP_FOLLOWUP_MESSAGE = "Confirm the email, then run `discolike auth login` again to sign in."


class _DeadClientError(Exception):
"""The authorization server no longer recognises the registered client_id."""

Expand All @@ -63,6 +68,23 @@ def _mask(key: str) -> str:
return "…" + key[-MASKED_VISIBLE_CHARS:]


def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
source = ctx.get_parameter_source(name)
return source is not None and source.name == "COMMANDLINE"


def _offer_signup(ctx: typer.Context) -> None:
email = typer.prompt("Work email")
first_name = typer.prompt("First name")
last_name = typer.prompt("Last name")
base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
run_signup(
email=email, first_name=first_name, last_name=last_name, agent=None, base_url=base_url, yes=False, fmt=None
)
typer.echo(SIGNUP_FOLLOWUP_MESSAGE)
raise typer.Exit(code=0)


def _iso(epoch_seconds: float) -> str:
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()

Expand Down Expand Up @@ -135,13 +157,11 @@ def _authorize(
open_browser: bool,
http: httpx2.Client,
) -> OAuthCredential:
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(STATE_BYTES)
url = build_authorization_url(
url, verifier = build_authorization_url(
metadata,
client_id=registration.client_id,
redirect_uri=registration.redirect_uri,
code_challenge=challenge,
state=state,
resource=resource,
)
Expand All @@ -168,7 +188,6 @@ def _authorize(
code_verifier=verifier,
redirect_uri=registration.redirect_uri,
resource=resource,
client=http,
)
except OAuthError as exc:
if exc.error in DEAD_CLIENT_ERRORS:
Expand Down Expand Up @@ -230,6 +249,14 @@ def login(
if method not in LOGIN_METHODS:
raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
if (
_is_interactive()
and api_key is None
and not global_key_passed
and not _was_passed_on_command_line(ctx, "method")
and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
):
_offer_signup(ctx)
if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
_api_key_login(ctx, api_key=api_key)
return
Expand Down
2 changes: 2 additions & 0 deletions packages/discolike-cli/src/discolike_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from discolike_cli import match
from discolike_cli import providers
from discolike_cli import queries
from discolike_cli import signup

app = typer.Typer(
name="discolike",
Expand Down Expand Up @@ -70,3 +71,4 @@ def get_client(ctx: typer.Context) -> Discolike:
app.command(name="validate-icp")(enrich.validate_icp_command)
app.command(name="append")(enrich.append_command)
app.command(name="segment")(enrich.segment_command)
app.command(name="signup")(signup.signup_command)
76 changes: 76 additions & 0 deletions packages/discolike-cli/src/discolike_cli/signup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import sys
from importlib.metadata import version as package_version

import typer

from discolike._config import DEFAULT_BASE_URL
from discolike._config import load_signup_email
from discolike.signup import signup
from discolike_cli._output import emit
from discolike_cli._output import handle_errors

FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
YES_HELP = "Skip the confirmation when signing up a different email than last time."
CLI_AGENT = f"discolike-cli/{package_version('discolike-cli')}"


def _is_interactive() -> bool:
return sys.stdin.isatty()


def _confirm_email_change(previous: str, email: str, *, yes: bool) -> bool:
if yes:
return True
prompt = f"This machine already signed up {previous}. Sign up {email} as well?"
if _is_interactive():
return typer.confirm(prompt, default=False)
typer.echo(f"{prompt} re-run with --yes", err=True)
return False


def run_signup(
*,
email: str,
first_name: str,
last_name: str,
agent: str | None,
base_url: str,
yes: bool,
fmt: str | None,
) -> None:
previous = load_signup_email()
allow_new_email = False
if previous is not None and previous.lower() != email.lower():
if not _confirm_email_change(previous, email, yes=yes):
raise typer.Exit(code=1)
allow_new_email = True
emit(
signup(
email=email,
first_name=first_name,
last_name=last_name,
agent=agent or CLI_AGENT,
base_url=base_url,
allow_new_email=allow_new_email,
),
fmt=fmt,
)


@handle_errors
def signup_command(
ctx: typer.Context,
email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
first_name: str = typer.Option(..., "--first-name"),
last_name: str = typer.Option(..., "--last-name"),
agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
) -> None:
"""Create a DiscoLike account for a person. No login needed; they confirm by email."""
base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
run_signup(
email=email, first_name=first_name, last_name=last_name, agent=agent, base_url=base_url, yes=yes, fmt=fmt
)
96 changes: 91 additions & 5 deletions packages/discolike-cli/tests/test_auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import time
from collections.abc import Callable
from typing import Any
from unittest.mock import patch
from urllib.parse import parse_qs
from urllib.parse import urlparse
from urllib.request import urlopen
Expand Down Expand Up @@ -75,18 +76,18 @@ def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[s
self.register_calls.append(redirect_uris)
return "client-1"

def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential:
def exchange_code(self, metadata: AuthServerMetadata, **kwargs: Any) -> OAuthCredential:
self.exchange_calls.append(kwargs)
if self.exchange_failures:
raise self.exchange_failures.pop(0)
return CREDENTIAL

def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str:
url = self.real_build_authorization_url(metadata, **kwargs)
def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> tuple[str, str]:
url, verifier = self.real_build_authorization_url(metadata, **kwargs)
query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
callback = f"{query['redirect_uri']}?{self.callback_query(query)}"
threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server
return url
return url, verifier

def open(self, url: str) -> bool:
self.opened_urls.append(url)
Expand Down Expand Up @@ -170,7 +171,9 @@ def test_login_timeout_exits_1(
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2)
monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never")
monkeypatch.setattr(
auth_module, "build_authorization_url", lambda metadata, **kwargs: ("https://auth.test/never", "v")
)
result = runner.invoke(app, ["auth", "login"])
assert result.exit_code == 1
assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"]
Expand Down Expand Up @@ -409,3 +412,86 @@ def test_login_forged_error_callback_cannot_evict_stored_client(
assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"]
assert provider.register_calls == []
assert load_oauth_client() == registration


def test_login_tty_confirms_existing_account_runs_oauth(
provider: FakeProvider,
install_build_client: Callable[[Handler], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
with patch("discolike_cli.auth.run_signup", autospec=True) as run_signup_mock:
result = runner.invoke(app, ["auth", "login"], input="y\n")
assert result.exit_code == 0, result.output
assert provider.discover_calls == [DEFAULT_BASE_URL]
run_signup_mock.assert_not_called()


def test_login_tty_declines_account_runs_signup(
provider: FakeProvider,
install_build_client: Callable[[Handler], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
with patch("discolike_cli.auth.run_signup", autospec=True, return_value=None) as run_signup_mock:
result = runner.invoke(app, ["auth", "login"], input="n\njane@acme.com\nJane\nDoe\n")
assert result.exit_code == 0, result.output
run_signup_mock.assert_called_once_with(
email="jane@acme.com",
first_name="Jane",
last_name="Doe",
agent=None,
base_url=DEFAULT_BASE_URL,
yes=False,
fmt=None,
)
assert "run `discolike auth login` again" in result.output
assert provider.discover_calls == []


def test_login_with_api_key_skips_account_question(install_build_client: Callable[[Handler], None]) -> None:
install_build_client(_usage_ok)
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
result = runner.invoke(app, ["auth", "login", "--api-key", "dk-1"])
assert result.exit_code == 0, result.output
confirm_mock.assert_not_called()


def test_login_with_global_api_key_skips_account_question(
install_build_client: Callable[[Handler], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"])
assert result.exit_code == 0, result.output
confirm_mock.assert_not_called()
assert json.loads(config_path().read_text())["api_key"] == "dk-global"


def test_login_with_explicit_api_key_method_skips_account_question(
install_build_client: Callable[[Handler], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
result = runner.invoke(app, ["auth", "login", "--method", "api_key", "--api-key", "dk-x"])
assert result.exit_code == 0, result.output
confirm_mock.assert_not_called()
assert json.loads(config_path().read_text())["api_key"] == "dk-x"


def test_login_rejects_unknown_method_on_a_tty_without_asking(
provider: FakeProvider,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
result = runner.invoke(app, ["auth", "login", "--method", "bogus"])
assert result.exit_code == 2
confirm_mock.assert_not_called()
assert provider.discover_calls == []
Loading
Loading