dogfu 0.4.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.
dogfu/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """dogfu — internal admin tooling (LinkedIn, X, Google, ChatGPT, SEO, Close CRM)
2
+ as one CLI and one importable SDK.
3
+
4
+ Each command/method is a thin, direct wrapper over a single backend capability and
5
+ returns a canonical dataclass model (so the same layer is usable from a program, not
6
+ just the shell). It is deliberately *not* organized around sales phases — composing
7
+ these tools into a workflow is the caller's (or the agent prompt's) job.
8
+
9
+ Auth is a single dogfu session token, minted by `dogfu configure` and resolved
10
+ from `DOGFU_TOKEN` or `~/.dogfu/config.json`. Every call (dogfu data API + CRM
11
+ proxy) goes through the Agent Berlin backend over Bearer auth.
12
+
13
+ Programmatic use:
14
+
15
+ from dogfu import Dogfu
16
+ dx = Dogfu()
17
+ dx.seo.domain_overview(target="acme.com") # -> DomainOverview
18
+
19
+ Layering: providers (raw transport) → normalize (dict→model) → models (the contract)
20
+ → sdk (typed clients) → client.Dogfu (facade) → commands (CLI).
21
+ """
22
+
23
+ # Defined before the imports below so modules that read it during import
24
+ # (e.g. providers.http's User-Agent) see a populated value.
25
+ __version__ = "0.4.0"
26
+
27
+ from .client import Dogfu
28
+ from .config import Config, RetryConfig
29
+ from .exceptions import (
30
+ ConfigError,
31
+ CustomFieldError,
32
+ DogfuAPIError,
33
+ DogfuAuthenticationError,
34
+ DogfuConnectionError,
35
+ DogfuError,
36
+ DogfuNotFoundError,
37
+ DogfuRateLimitError,
38
+ DogfuServerError,
39
+ )
40
+ from .session import configure_session, load_session_config
41
+
42
+ __all__ = [
43
+ "Dogfu",
44
+ "Config",
45
+ "RetryConfig",
46
+ "configure_session",
47
+ "load_session_config",
48
+ "DogfuError",
49
+ "ConfigError",
50
+ "CustomFieldError",
51
+ "DogfuAPIError",
52
+ "DogfuAuthenticationError",
53
+ "DogfuConnectionError",
54
+ "DogfuNotFoundError",
55
+ "DogfuRateLimitError",
56
+ "DogfuServerError",
57
+ "__version__",
58
+ ]
dogfu/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable `python -m dogfu`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
dogfu/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ """dogfu — internal admin tooling as one CLI.
2
+
3
+ Each group wraps one backend capability and prints a canonical JSON object (see
4
+ that command's --help for its exact `Output:` shape). Composing these into a
5
+ workflow is the caller's job, not the tool's.
6
+
7
+ \b
8
+ Setup
9
+ Auth is a single dogfu session token. Get an OTP from the dogfu MCP's
10
+ get_setup_instructions, then run:
11
+ dogfu configure --otp <OTP> --title "<what this session is for>"
12
+ The token is saved to ~/.dogfu/config.json (override with DOGFU_TOKEN).
13
+
14
+ \b
15
+ Discovery
16
+ dogfu --help list groups
17
+ dogfu <group> --help list a group's commands
18
+ dogfu <group> <cmd> --help full flags + the Output: data shape
19
+
20
+ \b
21
+ Output
22
+ Canonical JSON by default; -f table for humans; -o FILE to write to a file.
23
+
24
+ \b
25
+ Programmatic use
26
+ from dogfu import Dogfu; Dogfu().seo.domain_overview(target="acme.com")
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+ from typing import Optional
33
+
34
+ import click
35
+ import requests
36
+
37
+ from . import __version__
38
+ from .commands import chatgpt, crm, google, linkedin, seo, x
39
+ from .config import resolve_base_url
40
+ from .exceptions import DogfuError
41
+ from .session import configure_session
42
+
43
+
44
+ @click.group(help=__doc__)
45
+ @click.version_option(version=__version__, prog_name="dogfu")
46
+ def cli() -> None:
47
+ pass
48
+
49
+
50
+ for _group in (linkedin, x, google, chatgpt, seo, crm):
51
+ cli.add_command(_group)
52
+
53
+
54
+ @cli.command(hidden=True)
55
+ @click.option("--otp", required=True, help="One-time password from the dogfu MCP get_setup_instructions response.")
56
+ @click.option("--title", required=True, help="Short description of what this session is for.")
57
+ @click.option("--config-path", hidden=True, help="Custom config file path.")
58
+ @click.option("--base-url", default=None, hidden=True)
59
+ def configure(otp: str, title: str, config_path: Optional[str], base_url: Optional[str]) -> None:
60
+ """Exchange a dogfu OTP for a session token and persist it for later commands.
61
+
62
+ Hidden by design: invoked by the dogfu MCP bootstrap flow, not typed by humans.
63
+ """
64
+ # dogfu's base URL is the API root (no /sdk suffix), so POST directly.
65
+ root = resolve_base_url(base_url)
66
+ exchange_url = f"{root}/dogfucli/exchange-otp"
67
+
68
+ try:
69
+ resp = requests.post(
70
+ exchange_url,
71
+ json={"otp": otp, "title": title},
72
+ timeout=30,
73
+ )
74
+ except requests.RequestException as e:
75
+ raise click.ClickException(f"Failed to reach exchange endpoint: {e}")
76
+
77
+ if resp.status_code != 200:
78
+ # Surface the server's message verbatim (Echo returns {"message": "..."}),
79
+ # falling back to text/reason.
80
+ try:
81
+ body = resp.json()
82
+ detail = body.get("message") or body.get("error") or resp.text
83
+ except ValueError:
84
+ detail = resp.text or resp.reason
85
+ raise click.ClickException(f"OTP exchange failed ({resp.status_code}): {detail}")
86
+
87
+ token = resp.json().get("token")
88
+ if not token:
89
+ raise click.ClickException("Exchange response did not include a token.")
90
+
91
+ path = configure_session(token, config_path=config_path)
92
+ click.echo(f"dogfu session configured. Token saved to {path}.")
93
+
94
+
95
+ def main() -> None:
96
+ """CLI entrypoint. Renders DogfuError (SDK/transport errors) as a clean
97
+ one-line message instead of a traceback, while letting click handle its own
98
+ usage/help/version flow."""
99
+ try:
100
+ cli.main(standalone_mode=False, prog_name="dogfu")
101
+ except click.ClickException as e:
102
+ e.show()
103
+ sys.exit(e.exit_code)
104
+ except click.exceptions.Abort:
105
+ click.echo("Aborted!", err=True)
106
+ sys.exit(1)
107
+ except DogfuError as e:
108
+ click.echo(click.style(f"Error: {e}", fg="red"), err=True)
109
+ sys.exit(1)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
dogfu/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """The Dogfu facade — the entry point for both the CLI and programmatic use.
2
+
3
+ from dogfu import Dogfu
4
+ dx = Dogfu() # resolves the session token lazily
5
+ profiles = dx.linkedin.profiles(urls=[url]) # -> list[LinkedInProfile]
6
+ overview = dx.seo.domain_overview(target="acme.com")
7
+ lead = dx.crm.create_lead(name="Acme")
8
+
9
+ One Bearer-authenticated transport (the dogfu session token) backs everything:
10
+ the dogfu data API and the CRM proxy. The transport is built lazily, so
11
+ constructing `Dogfu` never resolves credentials — only touching a provider does,
12
+ which is what lets `dogfu configure` (and `--help`) run without a token. The raw
13
+ transports are also exposed (`dx.dogfu`, `dx.close`) for the rare case you
14
+ want the untouched provider payload.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+ from .config import Config, resolve_base_url, resolve_token
22
+ from .providers import CloseClient, DogfuClient
23
+ from .providers.http import HTTPClient
24
+ from .sdk import (
25
+ ChatGPTClient,
26
+ CrmClient,
27
+ GoogleClient,
28
+ LinkedInClient,
29
+ SeoClient,
30
+ XClient,
31
+ )
32
+
33
+
34
+ class Dogfu:
35
+ def __init__(
36
+ self,
37
+ token: Optional[str] = None,
38
+ base_url: Optional[str] = None,
39
+ config: Optional[Config] = None,
40
+ config_path: Optional[str] = None,
41
+ ) -> None:
42
+ self._config = config or Config(base_url=resolve_base_url(base_url))
43
+ self._token = token
44
+ self._config_path = config_path
45
+ self._http: Optional[HTTPClient] = None
46
+ self._dogfu: Optional[DogfuClient] = None
47
+ self._close: Optional[CloseClient] = None
48
+ self._crm: Optional[CrmClient] = None
49
+
50
+ @property
51
+ def config(self) -> Config:
52
+ return self._config
53
+
54
+ # The shared transport. Lazily resolves the token (env -> config file) and
55
+ # builds one Bearer-authenticated client reused by every provider.
56
+ @property
57
+ def http(self) -> HTTPClient:
58
+ if self._http is None:
59
+ token = resolve_token(self._token, self._config_path)
60
+ self._http = HTTPClient(
61
+ token,
62
+ self._config.base_url,
63
+ timeout=self._config.timeout,
64
+ retry_config=self._config.retry,
65
+ )
66
+ return self._http
67
+
68
+ # Raw transports (lazy; resolve credentials on first use).
69
+ @property
70
+ def dogfu(self) -> DogfuClient:
71
+ if self._dogfu is None:
72
+ self._dogfu = DogfuClient(self.http)
73
+ return self._dogfu
74
+
75
+ @property
76
+ def close(self) -> CloseClient:
77
+ if self._close is None:
78
+ self._close = CloseClient(self.http)
79
+ return self._close
80
+
81
+ # Typed clients (return models).
82
+ @property
83
+ def linkedin(self) -> LinkedInClient:
84
+ return LinkedInClient(self.dogfu)
85
+
86
+ @property
87
+ def x(self) -> XClient:
88
+ return XClient(self.dogfu)
89
+
90
+ @property
91
+ def google(self) -> GoogleClient:
92
+ return GoogleClient(self.dogfu)
93
+
94
+ @property
95
+ def chatgpt(self) -> ChatGPTClient:
96
+ return ChatGPTClient(self.dogfu)
97
+
98
+ @property
99
+ def seo(self) -> SeoClient:
100
+ return SeoClient(self.dogfu)
101
+
102
+ @property
103
+ def crm(self) -> CrmClient:
104
+ # Memoized: the CRM client caches the custom-field schema, so reusing one
105
+ # instance keeps curated-field reads/writes to a single schema fetch.
106
+ if self._crm is None:
107
+ self._crm = CrmClient(self.close)
108
+ return self._crm
@@ -0,0 +1,10 @@
1
+ """CLI command groups — one per provider/resource, flat. No workflow abstraction."""
2
+
3
+ from .chatgpt import chatgpt
4
+ from .crm import crm
5
+ from .google import google
6
+ from .linkedin import linkedin
7
+ from .seo import seo
8
+ from .x import x_group as x
9
+
10
+ __all__ = ["linkedin", "x", "google", "chatgpt", "seo", "crm"]
@@ -0,0 +1,69 @@
1
+ """Shared plumbing for the command layer.
2
+
3
+ `AppContext` lazily builds the Dogfu facade. `output_options` attaches the
4
+ universal output flags. `--raw` exists (returns the untouched provider payload) but
5
+ is **hidden** — it isn't shown in `--help` and isn't part of the documented surface,
6
+ so agents work with the canonical models by default.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Callable, Optional
13
+
14
+ import click
15
+
16
+ from ..client import Dogfu
17
+
18
+
19
+ class AppContext:
20
+ def __init__(self) -> None:
21
+ self._sx: Optional[Dogfu] = None
22
+
23
+ @property
24
+ def sx(self) -> Dogfu:
25
+ if self._sx is None:
26
+ self._sx = Dogfu()
27
+ return self._sx
28
+
29
+
30
+ pass_app = click.make_pass_decorator(AppContext, ensure=True)
31
+
32
+
33
+ def output_options(func: Callable) -> Callable:
34
+ """Attach -f/--format, -o/--output, and a hidden --raw to a leaf command."""
35
+ func = click.option(
36
+ "-o", "--output", "output",
37
+ type=click.Path(dir_okay=False, writable=True),
38
+ help="Write JSON to this file instead of stdout.",
39
+ )(func)
40
+ func = click.option(
41
+ "--raw", is_flag=True, hidden=True,
42
+ help="Internal: emit the untouched provider payload (undocumented).",
43
+ )(func)
44
+ func = click.option(
45
+ "-f", "--format", "fmt",
46
+ type=click.Choice(["json", "table"]), default="json", show_default=True,
47
+ help="Output format.",
48
+ )(func)
49
+ return func
50
+
51
+
52
+ def split_name(full: str) -> dict[str, str]:
53
+ """Split 'First Middle Last' into {first_name, last_name}."""
54
+ parts = full.strip().split()
55
+ if not parts:
56
+ raise click.UsageError("Empty name.")
57
+ if len(parts) == 1:
58
+ return {"first_name": parts[0], "last_name": ""}
59
+ return {"first_name": parts[0], "last_name": " ".join(parts[1:])}
60
+
61
+
62
+ def parse_filters(filters: Optional[str]) -> Any:
63
+ """Parse a --filters value as JSON, falling back to the raw string."""
64
+ if filters is None:
65
+ return None
66
+ try:
67
+ return json.loads(filters)
68
+ except json.JSONDecodeError:
69
+ return filters
@@ -0,0 +1,48 @@
1
+ """`dogfu chatgpt ...` — web-grounded ChatGPT answer with citations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..output import emit
8
+ from ._shared import output_options, pass_app
9
+
10
+
11
+ @click.group()
12
+ def chatgpt() -> None:
13
+ """Web-grounded ChatGPT answers with citations."""
14
+
15
+
16
+ @chatgpt.command("search")
17
+ @click.option("--query", required=True, help="The question.")
18
+ @click.option("--model", help="gpt-5.5 | gpt-5.4-mini | gpt-5.4-nano (default gpt-5.4-mini).")
19
+ @click.option("--system-prompt", help="Optional system instructions.")
20
+ @click.option("--search-context-size", type=click.Choice(["low", "medium", "high"]), help="Grounding context size.")
21
+ @click.option("--domain-filter", "domain_filter", multiple=True, help="Restrict grounding to this domain (repeatable).")
22
+ @click.option("--max-tokens", type=int, help="Cap response length.")
23
+ @click.option("--reasoning-effort", type=click.Choice(["minimal", "low", "medium", "high"]), help="Reasoning effort.")
24
+ @click.option("--include-citations/--no-include-citations", "include_citations", default=None, help="Include citations (default true).")
25
+ @click.option("--city", help="user_location.city")
26
+ @click.option("--region", help="user_location.region")
27
+ @click.option("--user-country", help="user_location.country")
28
+ @click.option("--timezone", help="user_location.timezone")
29
+ @output_options
30
+ @pass_app
31
+ def search(app, query, model, system_prompt, search_context_size, domain_filter,
32
+ max_tokens, reasoning_effort, include_citations, city, region,
33
+ user_country, timezone, fmt, raw, output):
34
+ """Ask ChatGPT a question with live web grounding.
35
+
36
+ Output: AnswerResult — query, engine ("chatgpt"), model, answer (text),
37
+ citations[] (title, url, snippet), usage{input_tokens, output_tokens,
38
+ total_tokens}.
39
+ """
40
+ user_location = {k: v for k, v in {
41
+ "city": city, "region": region, "country": user_country, "timezone": timezone,
42
+ }.items() if v is not None} or None
43
+ kw = dict(query=query, model=model, system_prompt=system_prompt,
44
+ search_context_size=search_context_size, domain_filter=list(domain_filter) or None,
45
+ max_tokens=max_tokens, reasoning_effort=reasoning_effort,
46
+ include_citations=include_citations, user_location=user_location)
47
+ data = app.sx.dogfu.chatgpt_search(**kw) if raw else app.sx.chatgpt.search(**kw)
48
+ emit(data, fmt=fmt, raw=raw, output=output, title="ChatGPT")