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.
Files changed (48) hide show
  1. cited_cli/__init__.py +1 -0
  2. cited_cli/__main__.py +3 -0
  3. cited_cli/api/__init__.py +0 -0
  4. cited_cli/api/client.py +111 -0
  5. cited_cli/api/endpoints.py +88 -0
  6. cited_cli/app.py +199 -0
  7. cited_cli/auth/__init__.py +0 -0
  8. cited_cli/auth/oauth_server.py +123 -0
  9. cited_cli/auth/store.py +83 -0
  10. cited_cli/commands/__init__.py +0 -0
  11. cited_cli/commands/agent.py +129 -0
  12. cited_cli/commands/analytics.py +83 -0
  13. cited_cli/commands/audit.py +177 -0
  14. cited_cli/commands/auth.py +353 -0
  15. cited_cli/commands/business.py +217 -0
  16. cited_cli/commands/config_cmd.py +111 -0
  17. cited_cli/commands/hq.py +125 -0
  18. cited_cli/commands/job.py +106 -0
  19. cited_cli/commands/mcp_cmd.py +30 -0
  20. cited_cli/commands/named_audit.py +244 -0
  21. cited_cli/commands/recommend.py +211 -0
  22. cited_cli/commands/solution.py +154 -0
  23. cited_cli/config/__init__.py +0 -0
  24. cited_cli/config/constants.py +48 -0
  25. cited_cli/config/manager.py +69 -0
  26. cited_cli/mcp/__init__.py +1 -0
  27. cited_cli/mcp/context.py +12 -0
  28. cited_cli/mcp/server.py +54 -0
  29. cited_cli/mcp/tools/__init__.py +1 -0
  30. cited_cli/mcp/tools/_helpers.py +33 -0
  31. cited_cli/mcp/tools/audit.py +129 -0
  32. cited_cli/mcp/tools/auth.py +23 -0
  33. cited_cli/mcp/tools/business.py +96 -0
  34. cited_cli/mcp/tools/job.py +57 -0
  35. cited_cli/mcp/tools/recommend.py +96 -0
  36. cited_cli/mcp/tools/solution.py +66 -0
  37. cited_cli/output/__init__.py +0 -0
  38. cited_cli/output/formatter.py +61 -0
  39. cited_cli/output/progress.py +56 -0
  40. cited_cli/output/tables.py +49 -0
  41. cited_cli/utils/__init__.py +0 -0
  42. cited_cli/utils/errors.py +31 -0
  43. cited_cli/utils/interactive.py +83 -0
  44. cited_cli-0.3.2.dist-info/METADATA +515 -0
  45. cited_cli-0.3.2.dist-info/RECORD +48 -0
  46. cited_cli-0.3.2.dist-info/WHEEL +4 -0
  47. cited_cli-0.3.2.dist-info/entry_points.txt +2 -0
  48. 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
@@ -0,0 +1,3 @@
1
+ from cited_cli.app import main
2
+
3
+ main()
File without changes
@@ -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;">&#10003; 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)
@@ -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