dogfu 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.
Files changed (46) hide show
  1. dogfu-0.4.0/.env.example +12 -0
  2. dogfu-0.4.0/.gitignore +8 -0
  3. dogfu-0.4.0/PKG-INFO +82 -0
  4. dogfu-0.4.0/README.md +67 -0
  5. dogfu-0.4.0/pyproject.toml +39 -0
  6. dogfu-0.4.0/src/dogfu/__init__.py +58 -0
  7. dogfu-0.4.0/src/dogfu/__main__.py +6 -0
  8. dogfu-0.4.0/src/dogfu/cli.py +113 -0
  9. dogfu-0.4.0/src/dogfu/client.py +108 -0
  10. dogfu-0.4.0/src/dogfu/commands/__init__.py +10 -0
  11. dogfu-0.4.0/src/dogfu/commands/_shared.py +69 -0
  12. dogfu-0.4.0/src/dogfu/commands/chatgpt.py +48 -0
  13. dogfu-0.4.0/src/dogfu/commands/crm.py +342 -0
  14. dogfu-0.4.0/src/dogfu/commands/google.py +52 -0
  15. dogfu-0.4.0/src/dogfu/commands/linkedin.py +113 -0
  16. dogfu-0.4.0/src/dogfu/commands/seo.py +183 -0
  17. dogfu-0.4.0/src/dogfu/commands/x.py +65 -0
  18. dogfu-0.4.0/src/dogfu/config.py +104 -0
  19. dogfu-0.4.0/src/dogfu/crm_fields.py +40 -0
  20. dogfu-0.4.0/src/dogfu/exceptions.py +76 -0
  21. dogfu-0.4.0/src/dogfu/models/__init__.py +60 -0
  22. dogfu-0.4.0/src/dogfu/models/base.py +36 -0
  23. dogfu-0.4.0/src/dogfu/models/crm.py +96 -0
  24. dogfu-0.4.0/src/dogfu/models/linkedin.py +135 -0
  25. dogfu-0.4.0/src/dogfu/models/search.py +58 -0
  26. dogfu-0.4.0/src/dogfu/models/seo.py +236 -0
  27. dogfu-0.4.0/src/dogfu/models/x.py +53 -0
  28. dogfu-0.4.0/src/dogfu/normalize/__init__.py +12 -0
  29. dogfu-0.4.0/src/dogfu/normalize/close.py +132 -0
  30. dogfu-0.4.0/src/dogfu/normalize/dogfu.py +532 -0
  31. dogfu-0.4.0/src/dogfu/normalize/helpers.py +69 -0
  32. dogfu-0.4.0/src/dogfu/output.py +94 -0
  33. dogfu-0.4.0/src/dogfu/providers/__init__.py +6 -0
  34. dogfu-0.4.0/src/dogfu/providers/close.py +207 -0
  35. dogfu-0.4.0/src/dogfu/providers/dogfu.py +127 -0
  36. dogfu-0.4.0/src/dogfu/providers/http.py +226 -0
  37. dogfu-0.4.0/src/dogfu/sdk/__init__.py +12 -0
  38. dogfu-0.4.0/src/dogfu/sdk/chatgpt.py +23 -0
  39. dogfu-0.4.0/src/dogfu/sdk/crm.py +179 -0
  40. dogfu-0.4.0/src/dogfu/sdk/google.py +22 -0
  41. dogfu-0.4.0/src/dogfu/sdk/linkedin.py +27 -0
  42. dogfu-0.4.0/src/dogfu/sdk/seo.py +60 -0
  43. dogfu-0.4.0/src/dogfu/sdk/x.py +19 -0
  44. dogfu-0.4.0/src/dogfu/session.py +85 -0
  45. dogfu-0.4.0/tests/smoke.py +341 -0
  46. dogfu-0.4.0/uv.lock +445 -0
@@ -0,0 +1,12 @@
1
+ # dogfu configuration.
2
+ #
3
+ # Auth is a single dogfu session token. The normal way to set it is:
4
+ # dogfu configure --otp <OTP> --title "<what this session is for>"
5
+ # which writes it to ~/.dogfu/config.json. The variables below are optional
6
+ # overrides (export them, or copy this file to .env and source it).
7
+
8
+ # Overrides the saved session token (takes precedence over ~/.dogfu/config.json).
9
+ # DOGFU_TOKEN=eyJ...
10
+
11
+ # Overrides the backend API root (for local/staging). Defaults to the prod URL.
12
+ # DOGFU_BASE_URL=https://backend.agentberlin.ai
dogfu-0.4.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .uv/
dogfu-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: dogfu
3
+ Version: 0.4.0
4
+ Summary: Internal admin/marketing CLI — discovery, enrichment, outreach, and CRM over one interface.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: requests>=2.31
8
+ Requires-Dist: rich>=13.7
9
+ Provides-Extra: dev
10
+ Requires-Dist: black>=24.0.0; extra == 'dev'
11
+ Requires-Dist: isort>=5.13.0; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
13
+ Requires-Dist: responses>=0.25.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dogfu
17
+
18
+ Internal admin CLI + SDK for Agent Berlin staff (`admin_users`). One
19
+ interface over LinkedIn, X, Google, ChatGPT, SEO (the dogfu data API) and the
20
+ Close CRM (via the admin CRM proxy). The sibling of `backend/sdk-python` — same
21
+ transport posture (a single Bearer token against the Berlin backend, retry with
22
+ backoff, a typed exception hierarchy, config-file session), scoped to the
23
+ internal admin surface instead of a customer project.
24
+
25
+ ## Auth
26
+
27
+ dogfu authenticates with a single **session token** (a short-lived JWT, 24h),
28
+ minted by exchanging a one-time OTP from the dogfu MCP's `get_setup_instructions`:
29
+
30
+ ```bash
31
+ dogfu configure --otp <OTP> --title "<what this session is for>"
32
+ ```
33
+
34
+ This `POST`s `{otp, title}` to `/dogfucli/exchange-otp` and saves the returned
35
+ token to `~/.dogfu/config.json`. Subsequent commands resolve the token from
36
+ `DOGFU_TOKEN` first, then that file. There is no refresh endpoint — on an auth
37
+ failure, run `get_setup_instructions` again for a fresh OTP and re-`configure`.
38
+
39
+ dogfu is **not project-scoped** (no `--domain`); access is platform-wide for
40
+ staff.
41
+
42
+ ## Endpoints
43
+
44
+ Everything goes through the Berlin backend (`https://backend.agentberlin.ai` by
45
+ default; override with `--base-url` / `DOGFU_BASE_URL`):
46
+
47
+ | Group | Backend route |
48
+ | --- | --- |
49
+ | `linkedin` / `x` / `google` / `chatgpt` / `seo` | `/api/v1/admin/dogfu/*` |
50
+ | `crm` | `/api/v1/admin/crm/*` — allowlisted proxy to Close CRM |
51
+
52
+ The CRM proxy holds the per-admin Close key server-side (configured from the
53
+ admin Console), so dogfu never sees a Close credential — it forwards Close
54
+ sub-paths under the dogfu session token.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ dogfu --help # list groups
60
+ dogfu <group> --help # list a group's commands
61
+ dogfu <group> <cmd> --help # flags + the Output: data shape
62
+
63
+ dogfu seo domain-overview --target acme.com
64
+ dogfu crm lead create --name "Acme" --url https://acme.com
65
+ ```
66
+
67
+ Output is canonical JSON by default (`-f table` for humans, `-o FILE` to write).
68
+
69
+ ## Programmatic use
70
+
71
+ ```python
72
+ from dogfu import Dogfu
73
+ dx = Dogfu() # resolves DOGFU_TOKEN / config file
74
+ dx.seo.domain_overview(target="acme.com") # -> DomainOverview
75
+ dx.crm.create_lead(name="Acme")
76
+ ```
77
+
78
+ ## Develop
79
+
80
+ ```bash
81
+ uv run python tests/smoke.py # offline checks (normalizers, models, SDK wiring)
82
+ ```
dogfu-0.4.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # dogfu
2
+
3
+ Internal admin CLI + SDK for Agent Berlin staff (`admin_users`). One
4
+ interface over LinkedIn, X, Google, ChatGPT, SEO (the dogfu data API) and the
5
+ Close CRM (via the admin CRM proxy). The sibling of `backend/sdk-python` — same
6
+ transport posture (a single Bearer token against the Berlin backend, retry with
7
+ backoff, a typed exception hierarchy, config-file session), scoped to the
8
+ internal admin surface instead of a customer project.
9
+
10
+ ## Auth
11
+
12
+ dogfu authenticates with a single **session token** (a short-lived JWT, 24h),
13
+ minted by exchanging a one-time OTP from the dogfu MCP's `get_setup_instructions`:
14
+
15
+ ```bash
16
+ dogfu configure --otp <OTP> --title "<what this session is for>"
17
+ ```
18
+
19
+ This `POST`s `{otp, title}` to `/dogfucli/exchange-otp` and saves the returned
20
+ token to `~/.dogfu/config.json`. Subsequent commands resolve the token from
21
+ `DOGFU_TOKEN` first, then that file. There is no refresh endpoint — on an auth
22
+ failure, run `get_setup_instructions` again for a fresh OTP and re-`configure`.
23
+
24
+ dogfu is **not project-scoped** (no `--domain`); access is platform-wide for
25
+ staff.
26
+
27
+ ## Endpoints
28
+
29
+ Everything goes through the Berlin backend (`https://backend.agentberlin.ai` by
30
+ default; override with `--base-url` / `DOGFU_BASE_URL`):
31
+
32
+ | Group | Backend route |
33
+ | --- | --- |
34
+ | `linkedin` / `x` / `google` / `chatgpt` / `seo` | `/api/v1/admin/dogfu/*` |
35
+ | `crm` | `/api/v1/admin/crm/*` — allowlisted proxy to Close CRM |
36
+
37
+ The CRM proxy holds the per-admin Close key server-side (configured from the
38
+ admin Console), so dogfu never sees a Close credential — it forwards Close
39
+ sub-paths under the dogfu session token.
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ dogfu --help # list groups
45
+ dogfu <group> --help # list a group's commands
46
+ dogfu <group> <cmd> --help # flags + the Output: data shape
47
+
48
+ dogfu seo domain-overview --target acme.com
49
+ dogfu crm lead create --name "Acme" --url https://acme.com
50
+ ```
51
+
52
+ Output is canonical JSON by default (`-f table` for humans, `-o FILE` to write).
53
+
54
+ ## Programmatic use
55
+
56
+ ```python
57
+ from dogfu import Dogfu
58
+ dx = Dogfu() # resolves DOGFU_TOKEN / config file
59
+ dx.seo.domain_overview(target="acme.com") # -> DomainOverview
60
+ dx.crm.create_lead(name="Acme")
61
+ ```
62
+
63
+ ## Develop
64
+
65
+ ```bash
66
+ uv run python tests/smoke.py # offline checks (normalizers, models, SDK wiring)
67
+ ```
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "dogfu"
3
+ version = "0.4.0"
4
+ description = "Internal admin/marketing CLI — discovery, enrichment, outreach, and CRM over one interface."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "click>=8.1",
9
+ "requests>=2.31",
10
+ "rich>=13.7",
11
+ ]
12
+
13
+ [project.scripts]
14
+ dogfu = "dogfu.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/dogfu"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "black>=24.0.0",
26
+ "isort>=5.13.0",
27
+ "pytest>=8.0.0",
28
+ "responses>=0.25.0",
29
+ ]
30
+
31
+ [tool.black]
32
+ line-length = 100
33
+
34
+ [tool.isort]
35
+ profile = "black"
36
+ line_length = 100
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ """Enable `python -m dogfu`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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()
@@ -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")