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.
- dogfu-0.4.0/.env.example +12 -0
- dogfu-0.4.0/.gitignore +8 -0
- dogfu-0.4.0/PKG-INFO +82 -0
- dogfu-0.4.0/README.md +67 -0
- dogfu-0.4.0/pyproject.toml +39 -0
- dogfu-0.4.0/src/dogfu/__init__.py +58 -0
- dogfu-0.4.0/src/dogfu/__main__.py +6 -0
- dogfu-0.4.0/src/dogfu/cli.py +113 -0
- dogfu-0.4.0/src/dogfu/client.py +108 -0
- dogfu-0.4.0/src/dogfu/commands/__init__.py +10 -0
- dogfu-0.4.0/src/dogfu/commands/_shared.py +69 -0
- dogfu-0.4.0/src/dogfu/commands/chatgpt.py +48 -0
- dogfu-0.4.0/src/dogfu/commands/crm.py +342 -0
- dogfu-0.4.0/src/dogfu/commands/google.py +52 -0
- dogfu-0.4.0/src/dogfu/commands/linkedin.py +113 -0
- dogfu-0.4.0/src/dogfu/commands/seo.py +183 -0
- dogfu-0.4.0/src/dogfu/commands/x.py +65 -0
- dogfu-0.4.0/src/dogfu/config.py +104 -0
- dogfu-0.4.0/src/dogfu/crm_fields.py +40 -0
- dogfu-0.4.0/src/dogfu/exceptions.py +76 -0
- dogfu-0.4.0/src/dogfu/models/__init__.py +60 -0
- dogfu-0.4.0/src/dogfu/models/base.py +36 -0
- dogfu-0.4.0/src/dogfu/models/crm.py +96 -0
- dogfu-0.4.0/src/dogfu/models/linkedin.py +135 -0
- dogfu-0.4.0/src/dogfu/models/search.py +58 -0
- dogfu-0.4.0/src/dogfu/models/seo.py +236 -0
- dogfu-0.4.0/src/dogfu/models/x.py +53 -0
- dogfu-0.4.0/src/dogfu/normalize/__init__.py +12 -0
- dogfu-0.4.0/src/dogfu/normalize/close.py +132 -0
- dogfu-0.4.0/src/dogfu/normalize/dogfu.py +532 -0
- dogfu-0.4.0/src/dogfu/normalize/helpers.py +69 -0
- dogfu-0.4.0/src/dogfu/output.py +94 -0
- dogfu-0.4.0/src/dogfu/providers/__init__.py +6 -0
- dogfu-0.4.0/src/dogfu/providers/close.py +207 -0
- dogfu-0.4.0/src/dogfu/providers/dogfu.py +127 -0
- dogfu-0.4.0/src/dogfu/providers/http.py +226 -0
- dogfu-0.4.0/src/dogfu/sdk/__init__.py +12 -0
- dogfu-0.4.0/src/dogfu/sdk/chatgpt.py +23 -0
- dogfu-0.4.0/src/dogfu/sdk/crm.py +179 -0
- dogfu-0.4.0/src/dogfu/sdk/google.py +22 -0
- dogfu-0.4.0/src/dogfu/sdk/linkedin.py +27 -0
- dogfu-0.4.0/src/dogfu/sdk/seo.py +60 -0
- dogfu-0.4.0/src/dogfu/sdk/x.py +19 -0
- dogfu-0.4.0/src/dogfu/session.py +85 -0
- dogfu-0.4.0/tests/smoke.py +341 -0
- dogfu-0.4.0/uv.lock +445 -0
dogfu-0.4.0/.env.example
ADDED
|
@@ -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
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,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")
|