cited-cli 0.3.2__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.
- cited_cli/__init__.py +1 -0
- cited_cli/__main__.py +3 -0
- cited_cli/api/__init__.py +0 -0
- cited_cli/api/client.py +111 -0
- cited_cli/api/endpoints.py +88 -0
- cited_cli/app.py +199 -0
- cited_cli/auth/__init__.py +0 -0
- cited_cli/auth/oauth_server.py +123 -0
- cited_cli/auth/store.py +83 -0
- cited_cli/commands/__init__.py +0 -0
- cited_cli/commands/agent.py +129 -0
- cited_cli/commands/analytics.py +83 -0
- cited_cli/commands/audit.py +177 -0
- cited_cli/commands/auth.py +353 -0
- cited_cli/commands/business.py +217 -0
- cited_cli/commands/config_cmd.py +111 -0
- cited_cli/commands/hq.py +125 -0
- cited_cli/commands/job.py +106 -0
- cited_cli/commands/mcp_cmd.py +30 -0
- cited_cli/commands/named_audit.py +244 -0
- cited_cli/commands/recommend.py +211 -0
- cited_cli/commands/solution.py +154 -0
- cited_cli/config/__init__.py +0 -0
- cited_cli/config/constants.py +48 -0
- cited_cli/config/manager.py +69 -0
- cited_cli/mcp/__init__.py +1 -0
- cited_cli/mcp/context.py +12 -0
- cited_cli/mcp/server.py +54 -0
- cited_cli/mcp/tools/__init__.py +1 -0
- cited_cli/mcp/tools/_helpers.py +33 -0
- cited_cli/mcp/tools/audit.py +129 -0
- cited_cli/mcp/tools/auth.py +23 -0
- cited_cli/mcp/tools/business.py +96 -0
- cited_cli/mcp/tools/job.py +57 -0
- cited_cli/mcp/tools/recommend.py +96 -0
- cited_cli/mcp/tools/solution.py +66 -0
- cited_cli/output/__init__.py +0 -0
- cited_cli/output/formatter.py +61 -0
- cited_cli/output/progress.py +56 -0
- cited_cli/output/tables.py +49 -0
- cited_cli/utils/__init__.py +0 -0
- cited_cli/utils/errors.py +31 -0
- cited_cli/utils/interactive.py +83 -0
- cited_cli-0.3.2.dist-info/METADATA +515 -0
- cited_cli-0.3.2.dist-info/RECORD +48 -0
- cited_cli-0.3.2.dist-info/WHEEL +4 -0
- cited_cli-0.3.2.dist-info/entry_points.txt +2 -0
- cited_cli-0.3.2.dist-info/licenses/LICENSE +4 -0
cited_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from cited_core import __version__ as __version__ # noqa: F401
|
cited_cli/__main__.py
ADDED
|
File without changes
|
cited_cli/api/client.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from cited_cli.utils.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,88 @@
|
|
|
1
|
+
# Auth
|
|
2
|
+
LOGIN = "/auth/login"
|
|
3
|
+
CLI_LOGIN = "/auth/cli-login"
|
|
4
|
+
CLI_REGISTER = "/auth/cli-register"
|
|
5
|
+
CLI_VERIFY_EMAIL = "/auth/cli-verify-email"
|
|
6
|
+
CLI_OAUTH_START = "/auth/cli-oauth-start"
|
|
7
|
+
LOGOUT = "/auth/logout"
|
|
8
|
+
ME = "/auth/me"
|
|
9
|
+
|
|
10
|
+
# System / Health
|
|
11
|
+
ROOT = "/"
|
|
12
|
+
HEALTH = "/health"
|
|
13
|
+
HEALTH_READY = "/health/ready"
|
|
14
|
+
HEALTH_LIVE = "/health/live"
|
|
15
|
+
HEALTH_APIS = "/health/apis"
|
|
16
|
+
|
|
17
|
+
# Business
|
|
18
|
+
BUSINESSES = "/businesses"
|
|
19
|
+
BUSINESS = "/businesses/{business_id}"
|
|
20
|
+
BUSINESS_LOGO = "/businesses/{business_id}/logo"
|
|
21
|
+
HEALTH_SCORES = "/businesses/{business_id}/health-scores"
|
|
22
|
+
HEALTH_SCORES_BREAKDOWN = "/businesses/{business_id}/health-scores/breakdown"
|
|
23
|
+
|
|
24
|
+
# Business Crawl
|
|
25
|
+
CRAWL_DATA = "/businesses/{business_id}/crawl-data"
|
|
26
|
+
CRAWL_START = "/businesses/{business_id}/crawl"
|
|
27
|
+
CRAWL_CANCEL = "/businesses/{business_id}/crawl/cancel"
|
|
28
|
+
|
|
29
|
+
# Named Audits (templates)
|
|
30
|
+
NAMED_AUDITS = "/named-audits"
|
|
31
|
+
NAMED_AUDIT = "/named-audits/{named_audit_id}"
|
|
32
|
+
|
|
33
|
+
# Audit
|
|
34
|
+
AUDIT_START = "/audit/start"
|
|
35
|
+
AUDIT_STATUS = "/audit/{job_id}/status"
|
|
36
|
+
AUDIT_RESULT = "/audit/{job_id}/result"
|
|
37
|
+
AUDIT_DETAILS = "/audit/{job_id}/details"
|
|
38
|
+
AUDIT_EXPORT_PDF = "/audit/{job_id}/export/pdf"
|
|
39
|
+
AUDIT_HISTORY = "/audit/history"
|
|
40
|
+
AUDIT_CANCEL = "/audit/{job_id}/cancel"
|
|
41
|
+
AUDIT_DELETE = "/audit/{job_id}"
|
|
42
|
+
AUDIT_PROVIDERS = "/audit/{job_id}/providers"
|
|
43
|
+
|
|
44
|
+
# Recommendations
|
|
45
|
+
RECOMMEND_START = "/recommendations/start"
|
|
46
|
+
RECOMMEND_STATUS = "/recommendations/{job_id}/status"
|
|
47
|
+
RECOMMEND_RESULT = "/recommendations/{job_id}/result"
|
|
48
|
+
RECOMMEND_HISTORY = "/recommendations/audit/{audit_job_id}/history"
|
|
49
|
+
RECOMMEND_EXPORT_PDF = "/recommendations/{job_id}/export/pdf"
|
|
50
|
+
RECOMMEND_CANCEL = "/recommendations/{job_id}/cancel"
|
|
51
|
+
RECOMMEND_DELETE = "/recommendations/{job_id}"
|
|
52
|
+
|
|
53
|
+
# Solutions
|
|
54
|
+
SOLUTION_REQUEST = "/solutions/request"
|
|
55
|
+
SOLUTION_CREATE = "/solutions/create"
|
|
56
|
+
SOLUTION_PRIORITY_ACTION = "/solutions/priority-action"
|
|
57
|
+
SOLUTION_STATUS = "/solutions/{job_id}/status"
|
|
58
|
+
SOLUTION_RESULT = "/solutions/{job_id}/result"
|
|
59
|
+
SOLUTION_HISTORY = "/solutions/history"
|
|
60
|
+
SOLUTION_CANCEL = "/solutions/{job_id}/cancel"
|
|
61
|
+
SOLUTION_DELETE = "/solutions/{job_id}"
|
|
62
|
+
|
|
63
|
+
# Business HQ
|
|
64
|
+
HQ = "/businesses/{business_id}/hq"
|
|
65
|
+
HQ_HEAVY = "/businesses/{business_id}/hq/heavy"
|
|
66
|
+
HQ_PRIORITY = "/businesses/{business_id}/hq/priority"
|
|
67
|
+
HQ_EXPORT_PDF = "/businesses/{business_id}/hq/export/pdf"
|
|
68
|
+
HQ_RECOMPUTE = "/businesses/{business_id}/hq/recompute"
|
|
69
|
+
PERSONAS = "/businesses/{business_id}/personas"
|
|
70
|
+
PRODUCTS = "/businesses/{business_id}/products"
|
|
71
|
+
BUYER_INTENTS = "/businesses/{business_id}/buyer-intents"
|
|
72
|
+
AGENTIC_READINESS = "/businesses/{business_id}/agentic-readiness"
|
|
73
|
+
TRUST_SIGNALS = "/businesses/{business_id}/trust-signals"
|
|
74
|
+
|
|
75
|
+
# Analytics
|
|
76
|
+
ANALYTICS_COMPARISON = "/analytics/audits/{audit_id}/comparison"
|
|
77
|
+
ANALYTICS_SUMMARY = "/analytics/businesses/{business_id}/summary"
|
|
78
|
+
ANALYTICS_TRENDS = "/analytics/businesses/{business_id}/trends"
|
|
79
|
+
ANALYTICS_ADVANCED = "/analytics/businesses/{business_id}/advanced-scoring"
|
|
80
|
+
|
|
81
|
+
# Agent Query API (v1)
|
|
82
|
+
AGENT_ROOT = "/agent/v1"
|
|
83
|
+
AGENT_BUSINESS = "/agent/v1/business/{business_id}"
|
|
84
|
+
AGENT_FACTS = "/agent/v1/business/{business_id}/facts"
|
|
85
|
+
AGENT_CLAIMS = "/agent/v1/business/{business_id}/claims"
|
|
86
|
+
AGENT_COMPARISON = "/agent/v1/business/{business_id}/comparison"
|
|
87
|
+
AGENT_SEMANTIC_HEALTH = "/agent/v1/business/{business_id}/semantic-health"
|
|
88
|
+
AGENT_BUYER_FIT = "/agent/v1/query/buyer-fit"
|
cited_cli/app.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from cited_cli import __version__
|
|
9
|
+
from cited_cli.commands.agent import agent_app
|
|
10
|
+
from cited_cli.commands.analytics import analytics_app
|
|
11
|
+
from cited_cli.commands.audit import audit_app
|
|
12
|
+
from cited_cli.commands.auth import auth_app, do_login, do_logout, do_register
|
|
13
|
+
from cited_cli.commands.business import business_app
|
|
14
|
+
from cited_cli.commands.config_cmd import config_app
|
|
15
|
+
from cited_cli.commands.hq import hq_app
|
|
16
|
+
from cited_cli.commands.job import job_app
|
|
17
|
+
from cited_cli.commands.mcp_cmd import mcp_app
|
|
18
|
+
from cited_cli.commands.named_audit import named_audit_app
|
|
19
|
+
from cited_cli.commands.recommend import recommend_app
|
|
20
|
+
from cited_cli.commands.solution import solution_app
|
|
21
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result
|
|
22
|
+
from cited_cli.output.tables import render_kv
|
|
23
|
+
from cited_cli.utils.errors import CitedAPIError, ExitCode, handle_api_error
|
|
24
|
+
from cited_core.api import endpoints
|
|
25
|
+
from cited_core.api.client import CitedClient
|
|
26
|
+
from cited_core.config.manager import ConfigManager
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="cited",
|
|
30
|
+
help="CLI for the Cited GEO platform.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
rich_markup_mode="rich",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Register nested subcommand groups
|
|
36
|
+
audit_app.add_typer(named_audit_app, name="template")
|
|
37
|
+
|
|
38
|
+
# Register subcommand groups
|
|
39
|
+
app.add_typer(auth_app, name="auth")
|
|
40
|
+
app.add_typer(config_app, name="config")
|
|
41
|
+
app.add_typer(business_app, name="business")
|
|
42
|
+
app.add_typer(audit_app, name="audit")
|
|
43
|
+
app.add_typer(recommend_app, name="recommend")
|
|
44
|
+
app.add_typer(solution_app, name="solution")
|
|
45
|
+
app.add_typer(hq_app, name="hq")
|
|
46
|
+
app.add_typer(analytics_app, name="analytics")
|
|
47
|
+
app.add_typer(agent_app, name="agent")
|
|
48
|
+
app.add_typer(job_app, name="job")
|
|
49
|
+
app.add_typer(mcp_app, name="mcp")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _version_callback(value: bool) -> None:
|
|
53
|
+
if value:
|
|
54
|
+
print(f"cited-cli {__version__}")
|
|
55
|
+
raise typer.Exit()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.callback()
|
|
59
|
+
def main_callback(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
version: Annotated[
|
|
62
|
+
bool | None,
|
|
63
|
+
typer.Option("--version", "-V", help="Show version and exit", callback=_version_callback,
|
|
64
|
+
is_eager=True),
|
|
65
|
+
] = None,
|
|
66
|
+
json_output: Annotated[
|
|
67
|
+
bool, typer.Option("--json", "-j", help="Output as JSON")
|
|
68
|
+
] = False,
|
|
69
|
+
text_output: Annotated[
|
|
70
|
+
bool, typer.Option("--text", "-t", help="Output as human-readable text (overrides config)")
|
|
71
|
+
] = False,
|
|
72
|
+
env: Annotated[
|
|
73
|
+
str | None, typer.Option("--env", "-e", help="Target environment (dev, prod, local)")
|
|
74
|
+
] = None,
|
|
75
|
+
profile: Annotated[
|
|
76
|
+
str | None, typer.Option("--profile", "-p", help="Config profile to use")
|
|
77
|
+
] = None,
|
|
78
|
+
verbose: Annotated[
|
|
79
|
+
bool, typer.Option("--verbose", "-v", help="Verbose output")
|
|
80
|
+
] = False,
|
|
81
|
+
quiet: Annotated[
|
|
82
|
+
bool, typer.Option("--quiet", "-q", help="Minimal output")
|
|
83
|
+
] = False,
|
|
84
|
+
no_color: Annotated[
|
|
85
|
+
bool, typer.Option("--no-color", help="Disable colored output")
|
|
86
|
+
] = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Cited GEO platform CLI."""
|
|
89
|
+
# Auto-detect non-TTY for color
|
|
90
|
+
if not sys.stdout.isatty():
|
|
91
|
+
no_color = True
|
|
92
|
+
|
|
93
|
+
config = ConfigManager()
|
|
94
|
+
profile_name = profile or "default"
|
|
95
|
+
|
|
96
|
+
# Resolve output format: --json/--text flags take precedence, then config, then default (text)
|
|
97
|
+
if json_output:
|
|
98
|
+
use_json = True
|
|
99
|
+
elif text_output:
|
|
100
|
+
use_json = False
|
|
101
|
+
else:
|
|
102
|
+
use_json = config.get("output", profile_name) == "json"
|
|
103
|
+
|
|
104
|
+
output_ctx = OutputContext(
|
|
105
|
+
json_mode=use_json,
|
|
106
|
+
quiet=quiet,
|
|
107
|
+
no_color=no_color,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
ctx.ensure_object(dict)
|
|
111
|
+
ctx.obj["output"] = output_ctx
|
|
112
|
+
ctx.obj["config"] = config
|
|
113
|
+
ctx.obj["profile"] = profile_name
|
|
114
|
+
ctx.obj["env_override"] = env
|
|
115
|
+
ctx.obj["verbose"] = verbose
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def version(ctx: typer.Context) -> None:
|
|
120
|
+
"""Show the CLI version."""
|
|
121
|
+
obj = ctx.obj or {}
|
|
122
|
+
out: OutputContext = obj.get("output", OutputContext())
|
|
123
|
+
print_result(
|
|
124
|
+
{"version": __version__, "name": "cited-cli"},
|
|
125
|
+
out,
|
|
126
|
+
human_formatter=lambda d, c: c.print(f"cited-cli {d['version']}"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def status(ctx: typer.Context) -> None:
|
|
132
|
+
"""Check API health."""
|
|
133
|
+
obj = ctx.obj or {}
|
|
134
|
+
out: OutputContext = obj.get("output", OutputContext())
|
|
135
|
+
cfg: ConfigManager = obj.get("config", ConfigManager())
|
|
136
|
+
profile = obj.get("profile", "default")
|
|
137
|
+
api_url = cfg.get_api_url(profile, obj.get("env_override"))
|
|
138
|
+
env = cfg.get_environment(profile, obj.get("env_override"))
|
|
139
|
+
|
|
140
|
+
client = CitedClient(base_url=api_url, timeout=10.0)
|
|
141
|
+
try:
|
|
142
|
+
data = client.get(endpoints.HEALTH_READY)
|
|
143
|
+
data["environment"] = env
|
|
144
|
+
data["api_url"] = api_url
|
|
145
|
+
print_result(
|
|
146
|
+
data,
|
|
147
|
+
out,
|
|
148
|
+
human_formatter=lambda d, c: render_kv("API Status", d, c),
|
|
149
|
+
)
|
|
150
|
+
except CitedAPIError as e:
|
|
151
|
+
handle_api_error(e, out.json_mode)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
print_error(f"Cannot reach API at {api_url}: {e}", out)
|
|
154
|
+
raise typer.Exit(ExitCode.ERROR) from e
|
|
155
|
+
finally:
|
|
156
|
+
client.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def login(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
email: Annotated[str | None, typer.Option(help="Email address")] = None,
|
|
163
|
+
password: Annotated[
|
|
164
|
+
str | None, typer.Option(help="Password", hide_input=True)
|
|
165
|
+
] = None,
|
|
166
|
+
provider: Annotated[
|
|
167
|
+
str | None,
|
|
168
|
+
typer.Option(help="OAuth provider: google, microsoft, github"),
|
|
169
|
+
] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Log in to Cited. Opens browser by default, or use --email for password login."""
|
|
172
|
+
do_login(ctx, email, password, provider)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def logout(ctx: typer.Context) -> None:
|
|
177
|
+
"""Log out and clear stored credentials."""
|
|
178
|
+
do_logout(ctx)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def register(
|
|
183
|
+
ctx: typer.Context,
|
|
184
|
+
email: Annotated[str | None, typer.Option(help="Email address")] = None,
|
|
185
|
+
name: Annotated[str | None, typer.Option(help="Full name")] = None,
|
|
186
|
+
password: Annotated[
|
|
187
|
+
str | None, typer.Option(help="Password", hide_input=True)
|
|
188
|
+
] = None,
|
|
189
|
+
provider: Annotated[
|
|
190
|
+
str | None,
|
|
191
|
+
typer.Option(help="OAuth provider: google, microsoft, github"),
|
|
192
|
+
] = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Register a new Cited account. Opens browser or use --email."""
|
|
195
|
+
do_register(ctx, email, name, password, provider)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> None:
|
|
199
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
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;}}
|
|
24
|
+
summary{{cursor:pointer;color:#64748b;font-size:0.9rem;}}</style></head>
|
|
25
|
+
<body><div class="card">
|
|
26
|
+
<h1 style="color:#22c55e;">✓ Authentication successful</h1>
|
|
27
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
28
|
+
<details class="token-section">
|
|
29
|
+
<summary>Terminal not responding? Copy your token manually</summary>
|
|
30
|
+
<p style="font-size:0.85rem;color:#475569;">
|
|
31
|
+
If the CLI didn't detect the login, copy this token and paste it in your terminal.</p>
|
|
32
|
+
<div class="token-box" id="token-text">{token}</div>
|
|
33
|
+
<button class="copy-btn" onclick="
|
|
34
|
+
var t=document.getElementById('token-text').textContent;
|
|
35
|
+
navigator.clipboard.writeText(t).then(function(){{this.textContent='Copied!'}}.bind(this))
|
|
36
|
+
">Copy token</button>
|
|
37
|
+
</details>
|
|
38
|
+
</div></body></html>
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_ERROR_HTML = """\
|
|
42
|
+
<!DOCTYPE html>
|
|
43
|
+
<html>
|
|
44
|
+
<head><meta charset="utf-8"><title>Cited CLI</title>
|
|
45
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
|
|
46
|
+
align-items:center;height:100vh;margin:0;background:#f8f9fa;}
|
|
47
|
+
.card{text-align:center;padding:2rem;}</style></head>
|
|
48
|
+
<body><div class="card">
|
|
49
|
+
<h1 style="color:#ef4444;">Authentication failed</h1>
|
|
50
|
+
<p>No token was received. Please try again.</p>
|
|
51
|
+
</div></body></html>
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_free_port() -> int:
|
|
56
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
57
|
+
s.bind(("127.0.0.1", 0))
|
|
58
|
+
port: int = s.getsockname()[1]
|
|
59
|
+
return port
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
63
|
+
def __init__( # type: ignore[no-untyped-def]
|
|
64
|
+
self, token_event: threading.Event, server_ref: OAuthCallbackServer, *args, **kwargs,
|
|
65
|
+
):
|
|
66
|
+
self._token_event = token_event
|
|
67
|
+
self._server_ref = server_ref
|
|
68
|
+
super().__init__(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def do_GET(self) -> None: # noqa: N802
|
|
71
|
+
parsed = urlparse(self.path)
|
|
72
|
+
if parsed.path != "/callback":
|
|
73
|
+
self.send_error(404)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
params = parse_qs(parsed.query)
|
|
77
|
+
token = params.get("token", [None])[0]
|
|
78
|
+
|
|
79
|
+
if token:
|
|
80
|
+
self._server_ref.token = token
|
|
81
|
+
success_html = _SUCCESS_HTML_TEMPLATE.format(token=html.escape(token))
|
|
82
|
+
self._send_html(200, success_html)
|
|
83
|
+
self._token_event.set()
|
|
84
|
+
else:
|
|
85
|
+
self._send_html(400, _ERROR_HTML)
|
|
86
|
+
|
|
87
|
+
def _send_html(self, code: int, html: str) -> None:
|
|
88
|
+
self.send_response(code)
|
|
89
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(html.encode())
|
|
92
|
+
|
|
93
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
94
|
+
pass # Suppress request logging
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OAuthCallbackServer:
|
|
98
|
+
"""Temporary localhost server to receive OAuth callback with token."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, timeout: float = 120.0) -> None:
|
|
101
|
+
self.token: str | None = None
|
|
102
|
+
self.port: int = _find_free_port()
|
|
103
|
+
self.redirect_uri = f"http://localhost:{self.port}/callback"
|
|
104
|
+
self._timeout = timeout
|
|
105
|
+
self._token_event = threading.Event()
|
|
106
|
+
self._server: HTTPServer | None = None
|
|
107
|
+
self._thread: threading.Thread | None = None
|
|
108
|
+
|
|
109
|
+
def start(self) -> None:
|
|
110
|
+
handler = partial(_CallbackHandler, self._token_event, self)
|
|
111
|
+
self._server = HTTPServer(("127.0.0.1", self.port), handler)
|
|
112
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
113
|
+
self._thread.start()
|
|
114
|
+
|
|
115
|
+
def wait_for_token(self) -> str | None:
|
|
116
|
+
self._token_event.wait(timeout=self._timeout)
|
|
117
|
+
return self.token
|
|
118
|
+
|
|
119
|
+
def shutdown(self) -> None:
|
|
120
|
+
if self._server:
|
|
121
|
+
self._server.shutdown()
|
|
122
|
+
if self._thread:
|
|
123
|
+
self._thread.join(timeout=5)
|
cited_cli/auth/store.py
ADDED
|
@@ -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_cli.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)
|
|
File without changes
|