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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# Changelog

## Unreleased
## 0.3.0 (unreleased)

- 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.
- SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced.
- CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr.
- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. If PropelAuth no longer recognises the stored client (`invalid_client` / `unauthorized_client`), login registers a fresh one and retries once.
- CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key.

- SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened.

## 0.3.0 (2026-08-27)
### Request models

- SDK (breaking): every request-taking method now takes a single request model instead of keyword arguments, and validates it locally before any HTTP call — a bad enum value, an out-of-range number, or a missing required field raises `pydantic.ValidationError` instead of a server 422. Models live in `discolike.requests` and are generated from the platform OpenAPI spec (`scripts/gen_requests.py`); query-param routes use `<Resource><Method>Params` (`MatchCompanyParams`, `ContactsSearchParams`, `DiscoverParams`, `CountParams`, `AppendParams`, `SegmentParams`, ...) and JSON-body routes use the platform's own names (`FindEmailRequest`, `ContactFilters`, `DiscoGenProcessRequest`, `UpdateQueryRequest`, ...). Path params and file uploads stay keyword arguments next to the model. Unknown fields pass through to the wire, so the SDK never blocks a platform field it does not know about yet.

Expand Down
2 changes: 1 addition & 1 deletion packages/discolike-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command
discolike auth login
```

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.
Opens your browser to log in (add `--no-browser` to print the URL instead, `--port` to pin the loopback port when forwarding over SSH). To use an API key instead, pass `--api-key KEY` or `--method api_key` to be prompted; 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.

## Quickstart

Expand Down
63 changes: 63 additions & 0 deletions packages/discolike-cli/src/discolike_cli/_loopback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import threading
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
from http.server import HTTPServer
from urllib.parse import parse_qs
from urllib.parse import urlparse

LOOPBACK_HOST = "127.0.0.1"
CALLBACK_PATH = "/callback"
CALLBACK_HTML = "<!doctype html><title>DiscoLike</title><p>Login complete. You can close this window.</p>"


class _LoopbackServer(HTTPServer):
def __init__(self, *, host: str, port: int) -> None:
super().__init__((host, port), _CallbackHandler)
self.query: dict[str, str] = {}
self.received = threading.Event()


class _CallbackHandler(BaseHTTPRequestHandler):
server: _LoopbackServer

def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_error(HTTPStatus.NOT_FOUND)
return
self.server.query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
body = CALLBACK_HTML.encode()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self.server.received.set()

def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- BaseHTTPRequestHandler signature
_ = (format, args)


class CallbackServer:
"""Loopback redirect target for the authorization-code flow; serves on a daemon thread."""

def __init__(self, *, port: int) -> None:
self._server = _LoopbackServer(host=LOOPBACK_HOST, port=port)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)

@property
def redirect_uri(self) -> str:
return f"http://{LOOPBACK_HOST}:{self._server.server_port}{CALLBACK_PATH}"

def wait(self, *, timeout: float) -> dict[str, str] | None:
return self._server.query if self._server.received.wait(timeout) else None

def __enter__(self) -> CallbackServer:
self._thread.start()
return self

def __exit__(self, *exc_info: object) -> None:
self._server.shutdown()
self._server.server_close()
Loading
Loading