cited-core 0.3.2__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.
- cited_core-0.3.2/.gitignore +19 -0
- cited_core-0.3.2/PKG-INFO +11 -0
- cited_core-0.3.2/pyproject.toml +20 -0
- cited_core-0.3.2/src/cited_core/__init__.py +1 -0
- cited_core-0.3.2/src/cited_core/api/__init__.py +1 -0
- cited_core-0.3.2/src/cited_core/api/client.py +111 -0
- cited_core-0.3.2/src/cited_core/api/endpoints.py +90 -0
- cited_core-0.3.2/src/cited_core/auth/__init__.py +1 -0
- cited_core-0.3.2/src/cited_core/auth/oauth_server.py +122 -0
- cited_core-0.3.2/src/cited_core/auth/store.py +83 -0
- cited_core-0.3.2/src/cited_core/config/__init__.py +1 -0
- cited_core-0.3.2/src/cited_core/config/constants.py +50 -0
- cited_core-0.3.2/src/cited_core/config/manager.py +69 -0
- cited_core-0.3.2/src/cited_core/errors.py +32 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cited-core
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Core library for the Cited GEO platform
|
|
5
|
+
Author-email: Cited <support@youcited.com>
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: keyring>=25.0.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cited-core"
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "Core library for the Cited GEO platform"
|
|
9
|
+
license = "LicenseRef-Proprietary"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "Cited", email = "support@youcited.com" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27.0",
|
|
14
|
+
"keyring>=25.0.0",
|
|
15
|
+
"tomli-w>=1.0.0",
|
|
16
|
+
"pydantic>=2.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/cited_core"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from cited_core.errors import CitedAPIError
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT = 30.0
|
|
10
|
+
LONG_TIMEOUT = 120.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CitedClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: str,
|
|
17
|
+
token: str | None = None,
|
|
18
|
+
agent_api_key: str | None = None,
|
|
19
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.token = token
|
|
23
|
+
self.agent_api_key = agent_api_key
|
|
24
|
+
headers: dict[str, str] = {
|
|
25
|
+
"Accept": "application/json",
|
|
26
|
+
}
|
|
27
|
+
cookies: dict[str, str] = {}
|
|
28
|
+
if token:
|
|
29
|
+
cookies["advgeo_session"] = token
|
|
30
|
+
if agent_api_key:
|
|
31
|
+
headers["X-Agent-API-Key"] = agent_api_key
|
|
32
|
+
self._client = httpx.Client(
|
|
33
|
+
base_url=self.base_url,
|
|
34
|
+
headers=headers,
|
|
35
|
+
cookies=cookies,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
follow_redirects=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
self._client.close()
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> CitedClient:
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *args: Any) -> None:
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
50
|
+
if response.is_success:
|
|
51
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
52
|
+
return response.json()
|
|
53
|
+
return response.text
|
|
54
|
+
# Try to extract error message from JSON body
|
|
55
|
+
message = f"HTTP {response.status_code}"
|
|
56
|
+
error_code = None
|
|
57
|
+
try:
|
|
58
|
+
body = response.json()
|
|
59
|
+
if isinstance(body, dict):
|
|
60
|
+
message = str(body.get("detail", body.get("message", message)))
|
|
61
|
+
error_code = body.get("error_code")
|
|
62
|
+
except Exception:
|
|
63
|
+
message = response.text or message
|
|
64
|
+
raise CitedAPIError(response.status_code, str(message), error_code)
|
|
65
|
+
|
|
66
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
67
|
+
response = self._client.get(path, params=params)
|
|
68
|
+
return self._handle_response(response)
|
|
69
|
+
|
|
70
|
+
def post(
|
|
71
|
+
self,
|
|
72
|
+
path: str,
|
|
73
|
+
json: dict[str, Any] | None = None,
|
|
74
|
+
data: dict[str, Any] | None = None,
|
|
75
|
+
timeout: float | None = None,
|
|
76
|
+
) -> Any:
|
|
77
|
+
kwargs: dict[str, Any] = {}
|
|
78
|
+
if json is not None:
|
|
79
|
+
kwargs["json"] = json
|
|
80
|
+
if data is not None:
|
|
81
|
+
kwargs["data"] = data
|
|
82
|
+
if timeout is not None:
|
|
83
|
+
kwargs["timeout"] = timeout
|
|
84
|
+
response = self._client.post(path, **kwargs)
|
|
85
|
+
return self._handle_response(response)
|
|
86
|
+
|
|
87
|
+
def put(self, path: str, json: dict[str, Any] | None = None) -> Any:
|
|
88
|
+
response = self._client.put(path, json=json or {})
|
|
89
|
+
return self._handle_response(response)
|
|
90
|
+
|
|
91
|
+
def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
|
|
92
|
+
response = self._client.patch(path, json=json or {})
|
|
93
|
+
return self._handle_response(response)
|
|
94
|
+
|
|
95
|
+
def delete(self, path: str) -> Any:
|
|
96
|
+
response = self._client.delete(path)
|
|
97
|
+
return self._handle_response(response)
|
|
98
|
+
|
|
99
|
+
def post_raw(
|
|
100
|
+
self,
|
|
101
|
+
path: str,
|
|
102
|
+
json: dict[str, Any] | None = None,
|
|
103
|
+
timeout: float | None = None,
|
|
104
|
+
) -> httpx.Response:
|
|
105
|
+
"""POST that returns the raw httpx.Response (for cookie extraction, etc.)."""
|
|
106
|
+
kwargs: dict[str, Any] = {}
|
|
107
|
+
if json is not None:
|
|
108
|
+
kwargs["json"] = json
|
|
109
|
+
if timeout is not None:
|
|
110
|
+
kwargs["timeout"] = timeout
|
|
111
|
+
return self._client.post(path, **kwargs)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Auth
|
|
4
|
+
LOGIN = "/auth/login"
|
|
5
|
+
CLI_LOGIN = "/auth/cli-login"
|
|
6
|
+
CLI_REGISTER = "/auth/cli-register"
|
|
7
|
+
CLI_VERIFY_EMAIL = "/auth/cli-verify-email"
|
|
8
|
+
CLI_OAUTH_START = "/auth/cli-oauth-start"
|
|
9
|
+
LOGOUT = "/auth/logout"
|
|
10
|
+
ME = "/auth/me"
|
|
11
|
+
|
|
12
|
+
# System / Health
|
|
13
|
+
ROOT = "/"
|
|
14
|
+
HEALTH = "/health"
|
|
15
|
+
HEALTH_READY = "/health/ready"
|
|
16
|
+
HEALTH_LIVE = "/health/live"
|
|
17
|
+
HEALTH_APIS = "/health/apis"
|
|
18
|
+
|
|
19
|
+
# Business
|
|
20
|
+
BUSINESSES = "/businesses"
|
|
21
|
+
BUSINESS = "/businesses/{business_id}"
|
|
22
|
+
BUSINESS_LOGO = "/businesses/{business_id}/logo"
|
|
23
|
+
HEALTH_SCORES = "/businesses/{business_id}/health-scores"
|
|
24
|
+
HEALTH_SCORES_BREAKDOWN = "/businesses/{business_id}/health-scores/breakdown"
|
|
25
|
+
|
|
26
|
+
# Business Crawl
|
|
27
|
+
CRAWL_DATA = "/businesses/{business_id}/crawl-data"
|
|
28
|
+
CRAWL_START = "/businesses/{business_id}/crawl"
|
|
29
|
+
CRAWL_CANCEL = "/businesses/{business_id}/crawl/cancel"
|
|
30
|
+
|
|
31
|
+
# Named Audits (templates)
|
|
32
|
+
NAMED_AUDITS = "/named-audits"
|
|
33
|
+
NAMED_AUDIT = "/named-audits/{named_audit_id}"
|
|
34
|
+
|
|
35
|
+
# Audit
|
|
36
|
+
AUDIT_START = "/audit/start"
|
|
37
|
+
AUDIT_STATUS = "/audit/{job_id}/status"
|
|
38
|
+
AUDIT_RESULT = "/audit/{job_id}/result"
|
|
39
|
+
AUDIT_DETAILS = "/audit/{job_id}/details"
|
|
40
|
+
AUDIT_EXPORT_PDF = "/audit/{job_id}/export/pdf"
|
|
41
|
+
AUDIT_HISTORY = "/audit/history"
|
|
42
|
+
AUDIT_CANCEL = "/audit/{job_id}/cancel"
|
|
43
|
+
AUDIT_DELETE = "/audit/{job_id}"
|
|
44
|
+
AUDIT_PROVIDERS = "/audit/{job_id}/providers"
|
|
45
|
+
|
|
46
|
+
# Recommendations
|
|
47
|
+
RECOMMEND_START = "/recommendations/start"
|
|
48
|
+
RECOMMEND_STATUS = "/recommendations/{job_id}/status"
|
|
49
|
+
RECOMMEND_RESULT = "/recommendations/{job_id}/result"
|
|
50
|
+
RECOMMEND_HISTORY = "/recommendations/audit/{audit_job_id}/history"
|
|
51
|
+
RECOMMEND_EXPORT_PDF = "/recommendations/{job_id}/export/pdf"
|
|
52
|
+
RECOMMEND_CANCEL = "/recommendations/{job_id}/cancel"
|
|
53
|
+
RECOMMEND_DELETE = "/recommendations/{job_id}"
|
|
54
|
+
|
|
55
|
+
# Solutions
|
|
56
|
+
SOLUTION_REQUEST = "/solutions/request"
|
|
57
|
+
SOLUTION_CREATE = "/solutions/create"
|
|
58
|
+
SOLUTION_PRIORITY_ACTION = "/solutions/priority-action"
|
|
59
|
+
SOLUTION_STATUS = "/solutions/{job_id}/status"
|
|
60
|
+
SOLUTION_RESULT = "/solutions/{job_id}/result"
|
|
61
|
+
SOLUTION_HISTORY = "/solutions/history"
|
|
62
|
+
SOLUTION_CANCEL = "/solutions/{job_id}/cancel"
|
|
63
|
+
SOLUTION_DELETE = "/solutions/{job_id}"
|
|
64
|
+
|
|
65
|
+
# Business HQ
|
|
66
|
+
HQ = "/businesses/{business_id}/hq"
|
|
67
|
+
HQ_HEAVY = "/businesses/{business_id}/hq/heavy"
|
|
68
|
+
HQ_PRIORITY = "/businesses/{business_id}/hq/priority"
|
|
69
|
+
HQ_EXPORT_PDF = "/businesses/{business_id}/hq/export/pdf"
|
|
70
|
+
HQ_RECOMPUTE = "/businesses/{business_id}/hq/recompute"
|
|
71
|
+
PERSONAS = "/businesses/{business_id}/personas"
|
|
72
|
+
PRODUCTS = "/businesses/{business_id}/products"
|
|
73
|
+
BUYER_INTENTS = "/businesses/{business_id}/buyer-intents"
|
|
74
|
+
AGENTIC_READINESS = "/businesses/{business_id}/agentic-readiness"
|
|
75
|
+
TRUST_SIGNALS = "/businesses/{business_id}/trust-signals"
|
|
76
|
+
|
|
77
|
+
# Analytics
|
|
78
|
+
ANALYTICS_COMPARISON = "/analytics/audits/{audit_id}/comparison"
|
|
79
|
+
ANALYTICS_SUMMARY = "/analytics/businesses/{business_id}/summary"
|
|
80
|
+
ANALYTICS_TRENDS = "/analytics/businesses/{business_id}/trends"
|
|
81
|
+
ANALYTICS_ADVANCED = "/analytics/businesses/{business_id}/advanced-scoring"
|
|
82
|
+
|
|
83
|
+
# Agent Query API (v1)
|
|
84
|
+
AGENT_ROOT = "/agent/v1"
|
|
85
|
+
AGENT_BUSINESS = "/agent/v1/business/{business_id}"
|
|
86
|
+
AGENT_FACTS = "/agent/v1/business/{business_id}/facts"
|
|
87
|
+
AGENT_CLAIMS = "/agent/v1/business/{business_id}/claims"
|
|
88
|
+
AGENT_COMPARISON = "/agent/v1/business/{business_id}/comparison"
|
|
89
|
+
AGENT_SEMANTIC_HEALTH = "/agent/v1/business/{business_id}/semantic-health"
|
|
90
|
+
AGENT_BUYER_FIT = "/agent/v1/query/buyer-fit"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
from functools import partial
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
_SUCCESS_HTML_TEMPLATE = """\
|
|
11
|
+
<!DOCTYPE html>
|
|
12
|
+
<html>
|
|
13
|
+
<head><meta charset="utf-8"><title>Cited CLI</title>
|
|
14
|
+
<style>body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;
|
|
15
|
+
align-items:center;height:100vh;margin:0;background:#f8f9fa;}}
|
|
16
|
+
.card{{text-align:center;padding:2rem;max-width:600px;}}
|
|
17
|
+
.token-section{{margin-top:1.5rem;text-align:left;}}
|
|
18
|
+
.token-box{{background:#f1f5f9;border:1px solid #cbd5e1;border-radius:6px;
|
|
19
|
+
padding:0.75rem;font-family:monospace;font-size:0.8rem;word-break:break-all;
|
|
20
|
+
max-height:4rem;overflow-y:auto;}}
|
|
21
|
+
.copy-btn{{margin-top:0.5rem;padding:0.4rem 1rem;background:#3b82f6;color:#fff;
|
|
22
|
+
border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;}}
|
|
23
|
+
.copy-btn:hover{{background:#2563eb;}}</style></head>
|
|
24
|
+
<body><div class="card">
|
|
25
|
+
<h1 style="color:#22c55e;">✓ Authentication successful</h1>
|
|
26
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
27
|
+
<details class="token-section">
|
|
28
|
+
<summary>Terminal not responding? Copy your token manually</summary>
|
|
29
|
+
<p style="font-size:0.85rem;color:#475569;">
|
|
30
|
+
If the CLI didn't detect the login, copy this token and paste it in your terminal.</p>
|
|
31
|
+
<div class="token-box" id="token-text">{token}</div>
|
|
32
|
+
<button class="copy-btn" onclick="
|
|
33
|
+
var t=document.getElementById('token-text').textContent;
|
|
34
|
+
navigator.clipboard.writeText(t).then(function(){{this.textContent='Copied!'}}.bind(this))
|
|
35
|
+
">Copy token</button>
|
|
36
|
+
</details>
|
|
37
|
+
</div></body></html>
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_ERROR_HTML = """\
|
|
41
|
+
<!DOCTYPE html>
|
|
42
|
+
<html>
|
|
43
|
+
<head><meta charset="utf-8"><title>Cited CLI</title>
|
|
44
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
|
|
45
|
+
align-items:center;height:100vh;margin:0;background:#f8f9fa;}
|
|
46
|
+
.card{text-align:center;padding:2rem;}</style></head>
|
|
47
|
+
<body><div class="card">
|
|
48
|
+
<h1 style="color:#ef4444;">Authentication failed</h1>
|
|
49
|
+
<p>No token was received. Please try again.</p>
|
|
50
|
+
</div></body></html>
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _find_free_port() -> int:
|
|
55
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
56
|
+
s.bind(("127.0.0.1", 0))
|
|
57
|
+
port: int = s.getsockname()[1]
|
|
58
|
+
return port
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
62
|
+
def __init__( # type: ignore[no-untyped-def]
|
|
63
|
+
self, token_event: threading.Event, server_ref: OAuthCallbackServer, *args, **kwargs,
|
|
64
|
+
):
|
|
65
|
+
self._token_event = token_event
|
|
66
|
+
self._server_ref = server_ref
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
def do_GET(self) -> None: # noqa: N802
|
|
70
|
+
parsed = urlparse(self.path)
|
|
71
|
+
if parsed.path != "/callback":
|
|
72
|
+
self.send_error(404)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
params = parse_qs(parsed.query)
|
|
76
|
+
token = params.get("token", [None])[0]
|
|
77
|
+
|
|
78
|
+
if token:
|
|
79
|
+
self._server_ref.token = token
|
|
80
|
+
success_html = _SUCCESS_HTML_TEMPLATE.format(token=html.escape(token))
|
|
81
|
+
self._send_html(200, success_html)
|
|
82
|
+
self._token_event.set()
|
|
83
|
+
else:
|
|
84
|
+
self._send_html(400, _ERROR_HTML)
|
|
85
|
+
|
|
86
|
+
def _send_html(self, code: int, html: str) -> None:
|
|
87
|
+
self.send_response(code)
|
|
88
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(html.encode())
|
|
91
|
+
|
|
92
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
93
|
+
pass # Suppress request logging
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OAuthCallbackServer:
|
|
97
|
+
"""Temporary localhost server to receive OAuth callback with token."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, timeout: float = 120.0) -> None:
|
|
100
|
+
self.token: str | None = None
|
|
101
|
+
self.port: int = _find_free_port()
|
|
102
|
+
self.redirect_uri = f"http://localhost:{self.port}/callback"
|
|
103
|
+
self._timeout = timeout
|
|
104
|
+
self._token_event = threading.Event()
|
|
105
|
+
self._server: HTTPServer | None = None
|
|
106
|
+
self._thread: threading.Thread | None = None
|
|
107
|
+
|
|
108
|
+
def start(self) -> None:
|
|
109
|
+
handler = partial(_CallbackHandler, self._token_event, self)
|
|
110
|
+
self._server = HTTPServer(("127.0.0.1", self.port), handler)
|
|
111
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
112
|
+
self._thread.start()
|
|
113
|
+
|
|
114
|
+
def wait_for_token(self) -> str | None:
|
|
115
|
+
self._token_event.wait(timeout=self._timeout)
|
|
116
|
+
return self.token
|
|
117
|
+
|
|
118
|
+
def shutdown(self) -> None:
|
|
119
|
+
if self._server:
|
|
120
|
+
self._server.shutdown()
|
|
121
|
+
if self._thread:
|
|
122
|
+
self._thread.join(timeout=5)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
|
|
8
|
+
from cited_core.config.constants import CONFIG_DIR, CREDENTIALS_FILE, KEYRING_SERVICE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenStore:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._keyring_available: bool | None = None
|
|
14
|
+
|
|
15
|
+
def _has_keyring(self) -> bool:
|
|
16
|
+
if self._keyring_available is None:
|
|
17
|
+
try:
|
|
18
|
+
import keyring
|
|
19
|
+
from keyring.backends.fail import Keyring as FailKeyring
|
|
20
|
+
|
|
21
|
+
backend = keyring.get_keyring()
|
|
22
|
+
self._keyring_available = not isinstance(backend, FailKeyring)
|
|
23
|
+
except Exception:
|
|
24
|
+
self._keyring_available = False
|
|
25
|
+
return self._keyring_available
|
|
26
|
+
|
|
27
|
+
def save_token(self, env: str, token: str) -> None:
|
|
28
|
+
if self._has_keyring():
|
|
29
|
+
import keyring
|
|
30
|
+
|
|
31
|
+
keyring.set_password(KEYRING_SERVICE, f"token:{env}", token)
|
|
32
|
+
else:
|
|
33
|
+
self._save_file(env, token)
|
|
34
|
+
|
|
35
|
+
def get_token(self, env: str) -> str | None:
|
|
36
|
+
if self._has_keyring():
|
|
37
|
+
import keyring
|
|
38
|
+
|
|
39
|
+
return keyring.get_password(KEYRING_SERVICE, f"token:{env}")
|
|
40
|
+
return self._load_file(env)
|
|
41
|
+
|
|
42
|
+
def delete_token(self, env: str) -> None:
|
|
43
|
+
if self._has_keyring():
|
|
44
|
+
import keyring
|
|
45
|
+
|
|
46
|
+
with contextlib.suppress(keyring.errors.PasswordDeleteError):
|
|
47
|
+
keyring.delete_password(KEYRING_SERVICE, f"token:{env}")
|
|
48
|
+
else:
|
|
49
|
+
self._delete_file(env)
|
|
50
|
+
|
|
51
|
+
def has_token(self, env: str) -> bool:
|
|
52
|
+
return self.get_token(env) is not None
|
|
53
|
+
|
|
54
|
+
# --- file fallback ---
|
|
55
|
+
|
|
56
|
+
def _ensure_creds_file(self) -> dict[str, str]:
|
|
57
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
if CREDENTIALS_FILE.exists():
|
|
59
|
+
with open(CREDENTIALS_FILE) as f:
|
|
60
|
+
data: dict[str, str] = json.load(f)
|
|
61
|
+
return data
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _write_creds(self, data: dict[str, str]) -> None:
|
|
65
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
67
|
+
json.dump(data, f, indent=2)
|
|
68
|
+
os.chmod(CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
|
69
|
+
|
|
70
|
+
def _save_file(self, env: str, token: str) -> None:
|
|
71
|
+
data = self._ensure_creds_file()
|
|
72
|
+
data[env] = token
|
|
73
|
+
self._write_creds(data)
|
|
74
|
+
|
|
75
|
+
def _load_file(self, env: str) -> str | None:
|
|
76
|
+
data = self._ensure_creds_file()
|
|
77
|
+
return data.get(env)
|
|
78
|
+
|
|
79
|
+
def _delete_file(self, env: str) -> None:
|
|
80
|
+
data = self._ensure_creds_file()
|
|
81
|
+
if env in data:
|
|
82
|
+
del data[env]
|
|
83
|
+
self._write_creds(data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ENVIRONMENTS: dict[str, str] = {
|
|
6
|
+
"prod": "https://api.youcited.com",
|
|
7
|
+
"dev": "https://dev.youcited.com",
|
|
8
|
+
"local": "http://localhost:8000",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
FRONTEND_URLS: dict[str, str] = {
|
|
12
|
+
"prod": "https://app.youcited.com",
|
|
13
|
+
"dev": "https://dev.youcited.com",
|
|
14
|
+
"local": "http://localhost:3000",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENV = "prod"
|
|
18
|
+
CONFIG_DIR = Path.home() / ".cited"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
20
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
21
|
+
KEYRING_SERVICE = "cited-cli"
|
|
22
|
+
|
|
23
|
+
VALID_INDUSTRIES = [
|
|
24
|
+
"automotive",
|
|
25
|
+
"beauty",
|
|
26
|
+
"consulting",
|
|
27
|
+
"education",
|
|
28
|
+
"entertainment",
|
|
29
|
+
"finance",
|
|
30
|
+
"fitness",
|
|
31
|
+
"government",
|
|
32
|
+
"healthcare",
|
|
33
|
+
"home_services",
|
|
34
|
+
"hospitality",
|
|
35
|
+
"legal",
|
|
36
|
+
"manufacturing",
|
|
37
|
+
"non_profit",
|
|
38
|
+
"real_estate",
|
|
39
|
+
"restaurant",
|
|
40
|
+
"retail",
|
|
41
|
+
"technology",
|
|
42
|
+
"other",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
VALID_SOURCE_TYPES = [
|
|
46
|
+
"question_insight",
|
|
47
|
+
"head_to_head",
|
|
48
|
+
"strengthening_tip",
|
|
49
|
+
"priority_action",
|
|
50
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import tomli_w
|
|
7
|
+
|
|
8
|
+
from cited_core.config.constants import CONFIG_DIR, CONFIG_FILE, DEFAULT_ENV, ENVIRONMENTS
|
|
9
|
+
|
|
10
|
+
VALID_KEYS = {
|
|
11
|
+
"environment",
|
|
12
|
+
"default_business_id",
|
|
13
|
+
"agent_api_key",
|
|
14
|
+
"output",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
VALID_OUTPUT_VALUES = {"json", "text"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigManager:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._ensure_config_dir()
|
|
23
|
+
self._data: dict[str, Any] = self._load()
|
|
24
|
+
|
|
25
|
+
def _ensure_config_dir(self) -> None:
|
|
26
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict[str, Any]:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return {"default": {}}
|
|
31
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
32
|
+
return tomllib.load(f)
|
|
33
|
+
|
|
34
|
+
def _save(self) -> None:
|
|
35
|
+
with open(CONFIG_FILE, "wb") as f:
|
|
36
|
+
tomli_w.dump(self._data, f)
|
|
37
|
+
|
|
38
|
+
def get(self, key: str, profile: str = "default") -> Any:
|
|
39
|
+
section = self._data.get(profile, self._data.get("default", {}))
|
|
40
|
+
return section.get(key)
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: str, profile: str = "default") -> None:
|
|
43
|
+
if profile not in self._data:
|
|
44
|
+
self._data[profile] = {}
|
|
45
|
+
self._data[profile][key] = value
|
|
46
|
+
self._save()
|
|
47
|
+
|
|
48
|
+
def delete(self, key: str, profile: str = "default") -> bool:
|
|
49
|
+
section = self._data.get(profile, {})
|
|
50
|
+
if key in section:
|
|
51
|
+
del section[key]
|
|
52
|
+
self._save()
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def get_all(self, profile: str = "default") -> dict[str, Any]:
|
|
57
|
+
return dict(self._data.get(profile, {}))
|
|
58
|
+
|
|
59
|
+
def get_environment(self, profile: str = "default", override: str | None = None) -> str:
|
|
60
|
+
if override:
|
|
61
|
+
return override
|
|
62
|
+
return self.get("environment", profile) or DEFAULT_ENV
|
|
63
|
+
|
|
64
|
+
def get_api_url(self, profile: str = "default", env_override: str | None = None) -> str:
|
|
65
|
+
env = self.get_environment(profile, env_override)
|
|
66
|
+
return ENVIRONMENTS.get(env, ENVIRONMENTS[DEFAULT_ENV])
|
|
67
|
+
|
|
68
|
+
def get_profiles(self) -> list[str]:
|
|
69
|
+
return [k for k in self._data if k != "default" and isinstance(self._data[k], dict)]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCode(IntEnum):
|
|
7
|
+
SUCCESS = 0
|
|
8
|
+
ERROR = 1
|
|
9
|
+
AUTH_ERROR = 2
|
|
10
|
+
NOT_FOUND = 3
|
|
11
|
+
VALIDATION_ERROR = 4
|
|
12
|
+
RATE_LIMITED = 5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CitedAPIError(Exception):
|
|
16
|
+
def __init__(self, status_code: int, message: str, error_code: str | None = None):
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.message = message
|
|
19
|
+
self.error_code = error_code
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def exit_code_for_status(status_code: int) -> ExitCode:
|
|
24
|
+
if status_code == 401 or status_code == 403:
|
|
25
|
+
return ExitCode.AUTH_ERROR
|
|
26
|
+
if status_code == 404:
|
|
27
|
+
return ExitCode.NOT_FOUND
|
|
28
|
+
if status_code == 422:
|
|
29
|
+
return ExitCode.VALIDATION_ERROR
|
|
30
|
+
if status_code == 429:
|
|
31
|
+
return ExitCode.RATE_LIMITED
|
|
32
|
+
return ExitCode.ERROR
|