rankrush-cli 0.1.37__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 (146) hide show
  1. rankrush/__init__.py +3 -0
  2. rankrush/cache.py +83 -0
  3. rankrush/cli.py +241 -0
  4. rankrush/client.py +227 -0
  5. rankrush/commands/__init__.py +0 -0
  6. rankrush/commands/audit.py +307 -0
  7. rankrush/commands/blog.py +175 -0
  8. rankrush/commands/buzz/__init__.py +0 -0
  9. rankrush/commands/buzz/drafts.py +84 -0
  10. rankrush/commands/buzz/posts.py +97 -0
  11. rankrush/commands/buzz/research.py +108 -0
  12. rankrush/commands/buzz/strategy.py +47 -0
  13. rankrush/commands/cms.py +80 -0
  14. rankrush/commands/init.py +223 -0
  15. rankrush/commands/keywords.py +91 -0
  16. rankrush/commands/leadgen.py +125 -0
  17. rankrush/commands/leads.py +253 -0
  18. rankrush/commands/nodes.py +227 -0
  19. rankrush/commands/profiles.py +64 -0
  20. rankrush/commands/report.py +279 -0
  21. rankrush/commands/scans.py +88 -0
  22. rankrush/commands/smoke.py +147 -0
  23. rankrush/commands/visibility.py +354 -0
  24. rankrush/config.py +44 -0
  25. rankrush/edge.py +299 -0
  26. rankrush/env_file.py +140 -0
  27. rankrush/errors.py +119 -0
  28. rankrush/first_run.py +117 -0
  29. rankrush/leadgen/__init__.py +39 -0
  30. rankrush/leadgen/dedupe.py +135 -0
  31. rankrush/leadgen/footprints.py +156 -0
  32. rankrush/leadgen/pipeline.py +191 -0
  33. rankrush/leadgen/sampler.py +161 -0
  34. rankrush/leadgen/store.py +101 -0
  35. rankrush/leadgen/wat_parser.py +180 -0
  36. rankrush/mcp_server.py +363 -0
  37. rankrush/options.py +40 -0
  38. rankrush/output.py +56 -0
  39. rankrush_cli-0.1.37.dist-info/METADATA +301 -0
  40. rankrush_cli-0.1.37.dist-info/RECORD +146 -0
  41. rankrush_cli-0.1.37.dist-info/WHEEL +5 -0
  42. rankrush_cli-0.1.37.dist-info/entry_points.txt +3 -0
  43. rankrush_cli-0.1.37.dist-info/licenses/LICENSE +21 -0
  44. rankrush_cli-0.1.37.dist-info/top_level.txt +2 -0
  45. rankrush_client/__init__.py +232 -0
  46. rankrush_client/api/__init__.py +11 -0
  47. rankrush_client/api/admin_api.py +1181 -0
  48. rankrush_client/api/audit_api.py +1178 -0
  49. rankrush_client/api/blog_api.py +1467 -0
  50. rankrush_client/api/buzz_api.py +2322 -0
  51. rankrush_client/api/jwt_only_api.py +2021 -0
  52. rankrush_client/api/profiles_api.py +600 -0
  53. rankrush_client/api/visibility_api.py +898 -0
  54. rankrush_client/api_client.py +804 -0
  55. rankrush_client/api_response.py +21 -0
  56. rankrush_client/configuration.py +640 -0
  57. rankrush_client/exceptions.py +218 -0
  58. rankrush_client/models/__init__.py +102 -0
  59. rankrush_client/models/audit_enqueue_request.py +104 -0
  60. rankrush_client/models/audit_enqueue_response.py +112 -0
  61. rankrush_client/models/audit_run.py +142 -0
  62. rankrush_client/models/audit_run_get_request.py +91 -0
  63. rankrush_client/models/audit_run_get_response.py +104 -0
  64. rankrush_client/models/blog_topic.py +108 -0
  65. rankrush_client/models/blog_topics_list_request.py +92 -0
  66. rankrush_client/models/blog_topics_list_response.py +101 -0
  67. rankrush_client/models/buzz_brand_research_response.py +100 -0
  68. rankrush_client/models/buzz_brand_research_response_research_summary.py +106 -0
  69. rankrush_client/models/buzz_brand_research_response_research_summary_sentiment_breakdown.py +92 -0
  70. rankrush_client/models/buzz_brand_research_response_research_summary_top_subreddits_inner.py +90 -0
  71. rankrush_client/models/buzz_draft_approve_request.py +92 -0
  72. rankrush_client/models/buzz_engagement.py +135 -0
  73. rankrush_client/models/buzz_engagements_list_request.py +98 -0
  74. rankrush_client/models/buzz_engagements_list_response.py +100 -0
  75. rankrush_client/models/buzz_extract_profile_response.py +94 -0
  76. rankrush_client/models/buzz_extract_profile_response_extracted.py +98 -0
  77. rankrush_client/models/buzz_generate_drafts_request.py +102 -0
  78. rankrush_client/models/buzz_generate_drafts_response.py +114 -0
  79. rankrush_client/models/buzz_mention.py +134 -0
  80. rankrush_client/models/buzz_opportunity_search_response.py +96 -0
  81. rankrush_client/models/buzz_profile_get_response.py +104 -0
  82. rankrush_client/models/buzz_research_list_request.py +94 -0
  83. rankrush_client/models/buzz_research_list_response.py +101 -0
  84. rankrush_client/models/buzz_visibility_score_response.py +125 -0
  85. rankrush_client/models/buzz_visibility_score_response_engagement_metrics.py +96 -0
  86. rankrush_client/models/buzz_visibility_score_response_top_subreddits_inner.py +94 -0
  87. rankrush_client/models/buzz_wait_request.py +90 -0
  88. rankrush_client/models/cap_error.py +108 -0
  89. rankrush_client/models/check_ai_visibility_request.py +112 -0
  90. rankrush_client/models/check_ai_visibility_response.py +114 -0
  91. rankrush_client/models/check_ai_visibility_response_dropped_inner.py +92 -0
  92. rankrush_client/models/create_lead_account_request.py +105 -0
  93. rankrush_client/models/create_lead_account_response.py +109 -0
  94. rankrush_client/models/create_lead_account_response_scan.py +99 -0
  95. rankrush_client/models/delete_audit_run_request.py +88 -0
  96. rankrush_client/models/delete_audit_run_response.py +101 -0
  97. rankrush_client/models/delete_audit_run_response_deleted.py +92 -0
  98. rankrush_client/models/error.py +102 -0
  99. rankrush_client/models/extract_offerings_request.py +90 -0
  100. rankrush_client/models/extract_offerings_response.py +108 -0
  101. rankrush_client/models/generate_blog_post_request.py +93 -0
  102. rankrush_client/models/generate_blog_post_response.py +98 -0
  103. rankrush_client/models/generate_blog_topics_request.py +93 -0
  104. rankrush_client/models/generate_content_strategy_response.py +97 -0
  105. rankrush_client/models/generate_node_content_request.py +133 -0
  106. rankrush_client/models/generate_node_content_response.py +101 -0
  107. rankrush_client/models/generate_node_content_response_content.py +143 -0
  108. rankrush_client/models/generate_visibility_keywords_response.py +115 -0
  109. rankrush_client/models/generate_visibility_keywords_response_keywords_inner.py +100 -0
  110. rankrush_client/models/generate_visibility_keywords_response_sources.py +90 -0
  111. rankrush_client/models/gsc_fetch_queries_response.py +105 -0
  112. rankrush_client/models/gsc_fetch_queries_response_queries_inner.py +96 -0
  113. rankrush_client/models/gsc_select_property_request.py +90 -0
  114. rankrush_client/models/job_queued_response.py +109 -0
  115. rankrush_client/models/keywords_list_request.py +92 -0
  116. rankrush_client/models/keywords_list_response.py +93 -0
  117. rankrush_client/models/lead_enqueue_request.py +98 -0
  118. rankrush_client/models/lead_enqueue_response.py +114 -0
  119. rankrush_client/models/lead_enqueue_result.py +103 -0
  120. rankrush_client/models/lead_item.py +110 -0
  121. rankrush_client/models/node_complete_request.py +92 -0
  122. rankrush_client/models/node_complete_response.py +108 -0
  123. rankrush_client/models/node_progress.py +115 -0
  124. rankrush_client/models/node_progress_summary.py +97 -0
  125. rankrush_client/models/nodes_list_request.py +104 -0
  126. rankrush_client/models/nodes_list_response.py +101 -0
  127. rankrush_client/models/ok_response.py +95 -0
  128. rankrush_client/models/profile.py +122 -0
  129. rankrush_client/models/profile_id_camel_request.py +88 -0
  130. rankrush_client/models/profile_id_snake_request.py +88 -0
  131. rankrush_client/models/profile_update_request.py +104 -0
  132. rankrush_client/models/profiles_list_request.py +89 -0
  133. rankrush_client/models/rate_limit_error.py +112 -0
  134. rankrush_client/models/report_job_get_request.py +89 -0
  135. rankrush_client/models/report_job_get_response.py +142 -0
  136. rankrush_client/models/save_cms_connection_request.py +96 -0
  137. rankrush_client/models/save_cms_connection_response.py +99 -0
  138. rankrush_client/models/suggest_competitors_request.py +118 -0
  139. rankrush_client/models/suggest_competitors_response.py +107 -0
  140. rankrush_client/models/suggest_competitors_response_category.py +96 -0
  141. rankrush_client/models/suggest_competitors_response_competitors_inner.py +102 -0
  142. rankrush_client/models/validation_error_body.py +102 -0
  143. rankrush_client/models/verify_blog_posts_request.py +90 -0
  144. rankrush_client/models/verify_blog_posts_response.py +98 -0
  145. rankrush_client/py.typed +0 -0
  146. rankrush_client/rest.py +263 -0
rankrush/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RankRush CLI — agent-native wrapper for the RankRush Supabase backend."""
2
+
3
+ __version__ = "0.1.37"
rankrush/cache.py ADDED
@@ -0,0 +1,83 @@
1
+ """Local file cache for time-series CLI data (visibility snapshots, etc.).
2
+
3
+ Cache directory: ~/.rankrush/cache/
4
+ Each key maps to a JSON file: ~/.rankrush/cache/<key>.json
5
+
6
+ File format:
7
+ {
8
+ "entries": [
9
+ {"ts": "2026-04-04T10:00:00", "data": { ... }},
10
+ ...
11
+ ]
12
+ }
13
+
14
+ Entries are appended in chronological order. The cache is append-only —
15
+ old entries are never deleted, enabling trend queries.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+
24
+ def _cache_dir() -> Path:
25
+ d = Path.home() / ".rankrush" / "cache"
26
+ d.mkdir(parents=True, exist_ok=True)
27
+ return d
28
+
29
+
30
+ def _cache_file(key: str) -> Path:
31
+ safe = key.replace("/", "_").replace(":", "_")
32
+ return _cache_dir() / f"{safe}.json"
33
+
34
+
35
+ def append(key: str, data: dict) -> str:
36
+ """Append a snapshot to the cache. Returns the timestamp written."""
37
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
38
+ path = _cache_file(key)
39
+
40
+ if path.exists():
41
+ with path.open() as f:
42
+ store = json.load(f)
43
+ else:
44
+ store = {"entries": []}
45
+
46
+ store["entries"].append({"ts": ts, "data": data})
47
+
48
+ with path.open("w") as f:
49
+ json.dump(store, f, indent=2)
50
+
51
+ return ts
52
+
53
+
54
+ def latest(key: str, n: int = 1) -> list[dict]:
55
+ """Return the N most recent entries for a key, newest first."""
56
+ path = _cache_file(key)
57
+ if not path.exists():
58
+ return []
59
+ with path.open() as f:
60
+ store = json.load(f)
61
+ return list(reversed(store.get("entries", [])))[:n]
62
+
63
+
64
+ def history(key: str, days: int = 7) -> list[dict]:
65
+ """Return all entries within the last `days` days, oldest first."""
66
+ from datetime import timedelta
67
+
68
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
69
+ path = _cache_file(key)
70
+ if not path.exists():
71
+ return []
72
+ with path.open() as f:
73
+ store = json.load(f)
74
+
75
+ result = []
76
+ for entry in store.get("entries", []):
77
+ try:
78
+ ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00"))
79
+ if ts >= cutoff:
80
+ result.append(entry)
81
+ except (KeyError, ValueError):
82
+ continue
83
+ return result
rankrush/cli.py ADDED
@@ -0,0 +1,241 @@
1
+ """RankRush CLI — entrypoint.
2
+
3
+ Usage:
4
+ rankrush <command> [options]
5
+
6
+ First time? Run ``rankrush init`` to set up your API key.
7
+
8
+ Environment (loaded from ~/.config/rankrush/.env, or set in your shell):
9
+ RANKRUSH_API_KEY Required — generate from dashboard → Settings → API Keys
10
+ SUPABASE_PUBLISHABLE_KEY Required — Supabase project anon (publishable) key
11
+ fallback name: RANKRUSH_SUPABASE_KEY
12
+
13
+ The Supabase URL is compiled in (rankrush.ai project). ``--stage`` targets the
14
+ stage environment instead (bundled stage URL + anon key); power users can also
15
+ set ``RANKRUSH_SUPABASE_URL`` / ``SUPABASE_PUBLISHABLE_KEY`` directly.
16
+
17
+ User-level config:
18
+ ~/.rankrush/config.toml [api] table with ``api_key`` key.
19
+ See cli/README.md.
20
+
21
+ Global flags:
22
+ --api-key KEY Override the RankRush API key.
23
+ --stage Target the stage environment (app.stage.rankrush.ai).
24
+ Uses RANKRUSH_STAGE_API_KEY; set it with
25
+ ``rankrush init --stage``.
26
+ --debug Re-raise the underlying exception (with traceback)
27
+ instead of printing the friendly one-line CliError.
28
+ """
29
+
30
+ import os
31
+
32
+ import click
33
+ from dotenv import load_dotenv
34
+
35
+ from rankrush import client, env_file, output
36
+ from rankrush.errors import CliError
37
+
38
+ # Load env vars in priority order. ``override=False`` everywhere means the
39
+ # first source wins (shell exports > repo cli/.env > user-level env file).
40
+ # (1) Shell environment is already in ``os.environ`` — nothing to do.
41
+ # (2) Repo-local cli/.env (when working from a repo checkout).
42
+ _repo_env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
43
+ load_dotenv(dotenv_path=_repo_env_path, override=False)
44
+ # (3) User-level env file (created by ``rankrush init``).
45
+ env_file.load_user_env(override=False)
46
+
47
+ from rankrush.commands.profiles import profiles_group
48
+ from rankrush.commands.visibility import visibility_group
49
+ from rankrush.commands.keywords import keywords_group
50
+ from rankrush.commands.buzz.drafts import drafts_group
51
+ from rankrush.commands.buzz.research import research_group
52
+ from rankrush.commands.buzz.strategy import strategy_group
53
+ from rankrush.commands.buzz.posts import posts_group
54
+ from rankrush.commands.smoke import smoke
55
+ from rankrush.commands.audit import audit_group
56
+ from rankrush.commands.nodes import nodes_group
57
+ from rankrush.commands.report import report_group
58
+ from rankrush.commands.leadgen import leadgen_group
59
+ from rankrush.commands.blog import blog_group
60
+ from rankrush.commands.cms import cms_group
61
+ from rankrush.commands.scans import scans_group
62
+ from rankrush.commands.leads import leads_group
63
+ from rankrush.commands.init import init
64
+
65
+
66
+ class CliErrorBoundaryGroup(click.Group):
67
+ """Top-level Click group that traps `CliError` from any sub-command.
68
+
69
+ By default, prints `ERROR: <human message>` to stderr (with secrets
70
+ scrubbed) and exits with code 1 — no Python traceback. With `--debug`
71
+ the original exception is re-raised so the traceback is visible.
72
+ """
73
+
74
+ def invoke(self, ctx: click.Context) -> object:
75
+ try:
76
+ return super().invoke(ctx)
77
+ except CliError as exc:
78
+ if ctx.obj and ctx.obj.get("debug"):
79
+ raise
80
+ as_json = _detect_json_flag(ctx)
81
+ output.error_message(str(exc), as_json=as_json)
82
+ ctx.exit(1)
83
+
84
+
85
+ def _detect_json_flag(ctx: click.Context) -> bool:
86
+ """Walk the context chain looking for an `as_json` parameter on any sub-command."""
87
+ current = ctx
88
+ while current is not None:
89
+ params = current.params or {}
90
+ if params.get("as_json"):
91
+ return True
92
+ current = current.parent
93
+ return False
94
+
95
+
96
+ _HELP_INTRO = """\
97
+ RankRush CLI — track and improve your AI discoverability from the terminal.
98
+
99
+ \b
100
+ First five minutes:
101
+ 1. Install: pip install rankrush-cli
102
+ 2. Configure: rankrush init (paste your API key)
103
+ 3. Verify: rankrush smoke (live health check)
104
+ 4. Explore: rankrush profiles list
105
+
106
+ Get an API key at https://app.rankrush.ai → Settings → API Keys.
107
+
108
+ Full docs: https://github.com/nikolapevic/rankrush#readme
109
+ """
110
+
111
+
112
+ @click.group(cls=CliErrorBoundaryGroup, help=_HELP_INTRO)
113
+ @click.option(
114
+ "--api-key",
115
+ "api_key",
116
+ envvar="RANKRUSH_API_KEY",
117
+ default=None,
118
+ help="Override the RankRush API key.",
119
+ )
120
+ @click.option(
121
+ "--stage",
122
+ is_flag=True,
123
+ default=False,
124
+ help="Target the stage environment (app.stage.rankrush.ai). Uses "
125
+ "RANKRUSH_STAGE_API_KEY — set it with `rankrush init --stage`.",
126
+ )
127
+ @click.option(
128
+ "--debug",
129
+ is_flag=True,
130
+ default=False,
131
+ help="Re-raise underlying exceptions with full traceback (developer mode).",
132
+ )
133
+ @click.version_option(package_name="rankrush-cli")
134
+ @click.pass_context
135
+ def cli(
136
+ ctx: click.Context,
137
+ api_key: str | None,
138
+ stage: bool,
139
+ debug: bool,
140
+ ):
141
+ ctx.ensure_object(dict)
142
+ ctx.obj["debug"] = debug
143
+ ctx.obj["api_key"] = api_key
144
+ ctx.obj["stage"] = stage
145
+ # Stash the flag values into the client module so the next ``_load()``
146
+ # call picks them up. Click resolves ``RANKRUSH_API_KEY`` into this arg
147
+ # automatically — but the layered resolver in ``rankrush.config`` must
148
+ # remain authoritative when the flag is absent, so we only forward a
149
+ # value that came from an explicit ``--api-key`` invocation. Click's
150
+ # ``envvar=`` would otherwise short-circuit the file lookup.
151
+ client.set_overrides(
152
+ api_key=api_key if _flag_was_explicit(ctx, "api_key") else None,
153
+ stage=stage,
154
+ )
155
+
156
+
157
+ def _flag_was_explicit(ctx: click.Context, name: str) -> bool:
158
+ """Return True only if a flag value came from the command line.
159
+
160
+ Click's ``get_parameter_source`` distinguishes COMMANDLINE from ENVIRONMENT
161
+ / DEFAULT / DEFAULT_MAP. When the value came from env, we want the
162
+ config-file layer to still win for the *other* setting if the user only
163
+ set one env var — so we let ``rankrush.config`` re-evaluate the env on
164
+ its own rather than treating Click's env-pull as a flag override.
165
+ """
166
+ source = ctx.get_parameter_source(name)
167
+ if source is None:
168
+ return False
169
+ return source == click.core.ParameterSource.COMMANDLINE
170
+
171
+
172
+ # Top-level commands
173
+ cli.add_command(init)
174
+ cli.add_command(smoke)
175
+ cli.add_command(profiles_group)
176
+ cli.add_command(visibility_group)
177
+ cli.add_command(keywords_group)
178
+ cli.add_command(audit_group)
179
+ cli.add_command(nodes_group)
180
+ cli.add_command(report_group)
181
+ cli.add_command(leadgen_group)
182
+ cli.add_command(blog_group)
183
+ cli.add_command(cms_group)
184
+ cli.add_command(scans_group)
185
+ cli.add_command(leads_group)
186
+
187
+ # buzz sub-group
188
+ @cli.group("buzz")
189
+ def buzz_group():
190
+ """Buzz (Reddit/Quora community engagement) commands."""
191
+
192
+
193
+ buzz_group.add_command(drafts_group)
194
+ buzz_group.add_command(research_group)
195
+ buzz_group.add_command(strategy_group)
196
+ buzz_group.add_command(posts_group)
197
+
198
+
199
+ @cli.command("repl")
200
+ def repl():
201
+ """Start an interactive REPL session.
202
+
203
+ Type commands as you would on the CLI (without the 'rankrush' prefix).
204
+ Type 'exit' or Ctrl-D to quit.
205
+
206
+ Example:
207
+ (rankrush) > buzz drafts list --json
208
+ (rankrush) > visibility check --profile-id 718bb7c7-ba2c-4e24-8333-5aac575c9cc0
209
+ """
210
+ import shlex
211
+
212
+ click.echo("RankRush REPL — type 'help' for commands, 'exit' to quit.")
213
+ while True:
214
+ try:
215
+ line = input("(rankrush) > ").strip()
216
+ except (EOFError, KeyboardInterrupt):
217
+ click.echo("\nBye.")
218
+ break
219
+
220
+ if not line:
221
+ continue
222
+ if line in ("exit", "quit"):
223
+ break
224
+ if line == "help":
225
+ click.echo(cli.get_help(click.Context(cli)))
226
+ continue
227
+
228
+ try:
229
+ args = shlex.split(line)
230
+ # Invoke as a standalone Click call so output goes to stdout normally
231
+ cli.main(args=args, standalone_mode=False)
232
+ except SystemExit:
233
+ pass # Click raises SystemExit(0) on --help; suppress it in REPL
234
+ except CliError as exc:
235
+ click.echo(f"Error: {exc}", err=True)
236
+ except Exception as e:
237
+ click.echo(f"Error: {e}", err=True)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ cli()
rankrush/client.py ADDED
@@ -0,0 +1,227 @@
1
+ """Supabase HTTP client — the real backend is always the hard dependency.
2
+
3
+ The Supabase URL is compiled in (``rankrush.config.DEFAULT_SUPABASE_URL``)
4
+ and points at the single rankrush.ai project — it is not overridable by
5
+ flag, env, or config file by design. Only the API key resolves via a
6
+ layered lookup (see ``rankrush.config``):
7
+
8
+ API key: ``--api-key`` flag → ``RANKRUSH_API_KEY`` env → (none)
9
+
10
+ The Supabase publishable (anon) key is bundled with the CLI — it is public
11
+ by design (the React app at app.rankrush.ai embeds the same value in every
12
+ browser session). It can still be overridden via ``SUPABASE_PUBLISHABLE_KEY``
13
+ or the fallback ``RANKRUSH_SUPABASE_KEY`` for self-hosters, but a fresh
14
+ install never needs to configure it.
15
+
16
+ All HTTP and JSON-decoding errors are raised as `CliError` subclasses
17
+ (`AuthError`, `NetworkError`, `ServerError`, `MalformedResponseError`,
18
+ `ConfigError`). Bare `requests` exceptions never escape this module.
19
+ """
20
+
21
+ import json
22
+ import os
23
+
24
+ import requests
25
+
26
+ from rankrush.config import (
27
+ DEFAULT_SUPABASE_URL,
28
+ STAGE_SUPABASE_URL,
29
+ resolve_api_key,
30
+ )
31
+ from rankrush.errors import (
32
+ AuthError,
33
+ ConfigError,
34
+ LimitExceededError,
35
+ MalformedResponseError,
36
+ NetworkError,
37
+ ServerError,
38
+ )
39
+
40
+ # Bundled Supabase publishable (anon) keys — public by design; the React apps
41
+ # embed these same values in every browser session, so they are safe to ship
42
+ # with the CLI. Selected by target (prod default, or stage via --stage).
43
+ # Self-hosters override via SUPABASE_PUBLISHABLE_KEY (or RANKRUSH_SUPABASE_KEY).
44
+ _BUNDLED_KEY = "sb_publishable_yvyF-kVnm5RQUJFbGRlMuQ_9e9HGLS3"
45
+ _STAGE_BUNDLED_KEY = "sb_publishable_9kp-2oku93xo4FexUvEbHQ_ftFqDouR"
46
+
47
+ _BASE: str | None = None
48
+ _KEY: str | None = None
49
+ _API_KEY: str | None = None
50
+
51
+ # CLI-flag overrides set by ``cli.py`` after Click parses the root group's
52
+ # ``--api-key`` / ``--stage`` options. Read by ``_load()`` on first request and
53
+ # never re-read until the module-level cache is reset (tests).
54
+ _FLAG_API_KEY: str | None = None
55
+ _STAGE: bool = False
56
+
57
+
58
+ def set_overrides(api_key: str | None = None, stage: bool = False) -> None:
59
+ """Stash the ``--api-key`` / ``--stage`` flag values for the next ``_load()``.
60
+
61
+ Called by the Click root group in ``rankrush.cli`` before any sub-command
62
+ runs. ``api_key`` may be None (flag not provided), in which case the
63
+ env/file chain handles resolution. ``stage`` selects the stage target
64
+ (URL + publishable key + ``RANKRUSH_STAGE_API_KEY``).
65
+ """
66
+ global _FLAG_API_KEY, _STAGE
67
+ _FLAG_API_KEY = api_key
68
+ _STAGE = stage
69
+
70
+
71
+ def _load() -> None:
72
+ """Resolve connection settings into module-level cache.
73
+
74
+ Idempotent — once ``_BASE`` is set the function is a no-op so per-request
75
+ callers don't pay re-resolution cost. Tests reset the three module
76
+ globals to None to force re-evaluation.
77
+ """
78
+ global _BASE, _KEY, _API_KEY
79
+ if _BASE is not None:
80
+ return
81
+
82
+ # Target selects the bundled URL + publishable key (prod default, or stage
83
+ # via --stage). RANKRUSH_SUPABASE_URL / SUPABASE_PUBLISHABLE_KEY still
84
+ # override the bundled defaults for power users / self-hosters.
85
+ default_url = STAGE_SUPABASE_URL if _STAGE else DEFAULT_SUPABASE_URL
86
+ default_key = _STAGE_BUNDLED_KEY if _STAGE else _BUNDLED_KEY
87
+ _BASE = os.getenv("RANKRUSH_SUPABASE_URL") or default_url
88
+ _KEY = (
89
+ os.getenv("SUPABASE_PUBLISHABLE_KEY")
90
+ or os.getenv("RANKRUSH_SUPABASE_KEY")
91
+ or default_key
92
+ )
93
+ _API_KEY = resolve_api_key(_FLAG_API_KEY, stage=_STAGE)
94
+
95
+ if not _API_KEY:
96
+ if _STAGE:
97
+ raise ConfigError(
98
+ "RANKRUSH_STAGE_API_KEY is not set. Run `rankrush init --stage` "
99
+ "to configure, or generate a key at app.stage.rankrush.ai → "
100
+ "Settings → API Keys."
101
+ )
102
+ raise ConfigError(
103
+ "RANKRUSH_API_KEY is not set. Run `rankrush init` to configure, "
104
+ "or generate a key in the RankRush dashboard → Settings → API Keys."
105
+ )
106
+
107
+
108
+ def _auth_headers() -> dict:
109
+ _load()
110
+ return {
111
+ "Authorization": f"Bearer {_API_KEY}",
112
+ "apikey": _KEY,
113
+ "Content-Type": "application/json",
114
+ }
115
+
116
+
117
+ def _raise_for_status(resp: requests.Response, context: str) -> None:
118
+ """Translate an HTTP error response into the appropriate CliError subclass.
119
+
120
+ `context` is a short label (e.g. function name or table name) used in the
121
+ user-facing message. Never include URL, key, or response body — those may
122
+ contain secrets.
123
+ """
124
+ status = resp.status_code
125
+ if status < 400:
126
+ return
127
+
128
+ if status in (401, 403):
129
+ raise AuthError(
130
+ "API key rejected. Run `rankrush init` to (re)configure, or check "
131
+ "`RANKRUSH_API_KEY` in your .env or environment."
132
+ )
133
+ if status >= 500:
134
+ raise ServerError(
135
+ f"RankRush backend returned {status} for {context}. Try again in a minute."
136
+ )
137
+ # 422 with a tier-limit body is part of the contract — surface the
138
+ # structured fields as LimitExceededError so callers can format an
139
+ # actionable message. Any other 4xx still falls through to generic
140
+ # ServerError to avoid leaking PII from arbitrary error bodies.
141
+ if status == 422:
142
+ try:
143
+ body = resp.json()
144
+ except (json.JSONDecodeError, ValueError):
145
+ body = None
146
+ if isinstance(body, dict) and body.get("code") in (
147
+ "keyword_limit_exceeded",
148
+ "profile_keyword_limit",
149
+ "limit_exceeded",
150
+ ):
151
+ code = str(body["code"])
152
+ limit = body.get("limit")
153
+ used = body.get("used")
154
+ tier = body.get("tier")
155
+ upgrade_to = body.get("upgrade_to")
156
+ if code == "profile_keyword_limit":
157
+ msg = (
158
+ f"Per-profile keyword limit reached ({used}/{limit}). "
159
+ "Remove keywords from this profile before generating more."
160
+ )
161
+ else:
162
+ tier_label = (tier or "your").capitalize() if tier else "your"
163
+ if upgrade_to:
164
+ msg = (
165
+ f"You're at your keyword limit ({used}/{limit} for {tier_label} tier). "
166
+ f"Upgrade to {str(upgrade_to).capitalize()} for more: "
167
+ "https://app.rankrush.ai/billing"
168
+ )
169
+ else:
170
+ msg = (
171
+ f"You're at your keyword limit ({used}/{limit}). "
172
+ "Contact sales for a higher cap."
173
+ )
174
+ raise LimitExceededError(
175
+ msg,
176
+ code=code,
177
+ limit=limit,
178
+ used=used,
179
+ tier=tier,
180
+ upgrade_to=upgrade_to,
181
+ )
182
+ # 4xx other than auth — surface the status code only; body may contain PII.
183
+ raise ServerError(
184
+ f"RankRush backend returned {status} for {context}."
185
+ )
186
+
187
+
188
+ def _decode_json(resp: requests.Response, context: str):
189
+ """Parse a JSON response body. Raises MalformedResponseError on failure."""
190
+ try:
191
+ return resp.json()
192
+ except (json.JSONDecodeError, ValueError) as exc:
193
+ raise MalformedResponseError(
194
+ f"Backend returned a non-JSON response for {context}."
195
+ ) from exc
196
+
197
+
198
+ def invoke_function(name: str, body: dict, timeout: int = 60) -> dict:
199
+ """POST to a Supabase Edge Function.
200
+
201
+ Raises:
202
+ AuthError — backend rejected the API key
203
+ NetworkError — connection failure or timeout
204
+ ServerError — backend returned 4xx/5xx
205
+ MalformedResponseError — response body is not valid JSON
206
+ ConfigError — required env vars missing
207
+ """
208
+ _load()
209
+ url = f"{_BASE}/functions/v1/{name}"
210
+ try:
211
+ resp = requests.post(url, json=body, headers=_auth_headers(), timeout=timeout)
212
+ except requests.exceptions.Timeout as exc:
213
+ raise NetworkError(
214
+ f"Request to edge function {name!r} timed out after {timeout}s."
215
+ ) from exc
216
+ except requests.exceptions.ConnectionError as exc:
217
+ raise NetworkError(
218
+ "Couldn't reach the RankRush API. Check your internet connection."
219
+ ) from exc
220
+ except requests.exceptions.RequestException as exc:
221
+ raise NetworkError(
222
+ f"Network error calling edge function {name!r}."
223
+ ) from exc
224
+
225
+ _raise_for_status(resp, context=f"edge function {name!r}")
226
+ return _decode_json(resp, context=f"edge function {name!r}")
227
+
File without changes