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.
- rankrush/__init__.py +3 -0
- rankrush/cache.py +83 -0
- rankrush/cli.py +241 -0
- rankrush/client.py +227 -0
- rankrush/commands/__init__.py +0 -0
- rankrush/commands/audit.py +307 -0
- rankrush/commands/blog.py +175 -0
- rankrush/commands/buzz/__init__.py +0 -0
- rankrush/commands/buzz/drafts.py +84 -0
- rankrush/commands/buzz/posts.py +97 -0
- rankrush/commands/buzz/research.py +108 -0
- rankrush/commands/buzz/strategy.py +47 -0
- rankrush/commands/cms.py +80 -0
- rankrush/commands/init.py +223 -0
- rankrush/commands/keywords.py +91 -0
- rankrush/commands/leadgen.py +125 -0
- rankrush/commands/leads.py +253 -0
- rankrush/commands/nodes.py +227 -0
- rankrush/commands/profiles.py +64 -0
- rankrush/commands/report.py +279 -0
- rankrush/commands/scans.py +88 -0
- rankrush/commands/smoke.py +147 -0
- rankrush/commands/visibility.py +354 -0
- rankrush/config.py +44 -0
- rankrush/edge.py +299 -0
- rankrush/env_file.py +140 -0
- rankrush/errors.py +119 -0
- rankrush/first_run.py +117 -0
- rankrush/leadgen/__init__.py +39 -0
- rankrush/leadgen/dedupe.py +135 -0
- rankrush/leadgen/footprints.py +156 -0
- rankrush/leadgen/pipeline.py +191 -0
- rankrush/leadgen/sampler.py +161 -0
- rankrush/leadgen/store.py +101 -0
- rankrush/leadgen/wat_parser.py +180 -0
- rankrush/mcp_server.py +363 -0
- rankrush/options.py +40 -0
- rankrush/output.py +56 -0
- rankrush_cli-0.1.37.dist-info/METADATA +301 -0
- rankrush_cli-0.1.37.dist-info/RECORD +146 -0
- rankrush_cli-0.1.37.dist-info/WHEEL +5 -0
- rankrush_cli-0.1.37.dist-info/entry_points.txt +3 -0
- rankrush_cli-0.1.37.dist-info/licenses/LICENSE +21 -0
- rankrush_cli-0.1.37.dist-info/top_level.txt +2 -0
- rankrush_client/__init__.py +232 -0
- rankrush_client/api/__init__.py +11 -0
- rankrush_client/api/admin_api.py +1181 -0
- rankrush_client/api/audit_api.py +1178 -0
- rankrush_client/api/blog_api.py +1467 -0
- rankrush_client/api/buzz_api.py +2322 -0
- rankrush_client/api/jwt_only_api.py +2021 -0
- rankrush_client/api/profiles_api.py +600 -0
- rankrush_client/api/visibility_api.py +898 -0
- rankrush_client/api_client.py +804 -0
- rankrush_client/api_response.py +21 -0
- rankrush_client/configuration.py +640 -0
- rankrush_client/exceptions.py +218 -0
- rankrush_client/models/__init__.py +102 -0
- rankrush_client/models/audit_enqueue_request.py +104 -0
- rankrush_client/models/audit_enqueue_response.py +112 -0
- rankrush_client/models/audit_run.py +142 -0
- rankrush_client/models/audit_run_get_request.py +91 -0
- rankrush_client/models/audit_run_get_response.py +104 -0
- rankrush_client/models/blog_topic.py +108 -0
- rankrush_client/models/blog_topics_list_request.py +92 -0
- rankrush_client/models/blog_topics_list_response.py +101 -0
- rankrush_client/models/buzz_brand_research_response.py +100 -0
- rankrush_client/models/buzz_brand_research_response_research_summary.py +106 -0
- rankrush_client/models/buzz_brand_research_response_research_summary_sentiment_breakdown.py +92 -0
- rankrush_client/models/buzz_brand_research_response_research_summary_top_subreddits_inner.py +90 -0
- rankrush_client/models/buzz_draft_approve_request.py +92 -0
- rankrush_client/models/buzz_engagement.py +135 -0
- rankrush_client/models/buzz_engagements_list_request.py +98 -0
- rankrush_client/models/buzz_engagements_list_response.py +100 -0
- rankrush_client/models/buzz_extract_profile_response.py +94 -0
- rankrush_client/models/buzz_extract_profile_response_extracted.py +98 -0
- rankrush_client/models/buzz_generate_drafts_request.py +102 -0
- rankrush_client/models/buzz_generate_drafts_response.py +114 -0
- rankrush_client/models/buzz_mention.py +134 -0
- rankrush_client/models/buzz_opportunity_search_response.py +96 -0
- rankrush_client/models/buzz_profile_get_response.py +104 -0
- rankrush_client/models/buzz_research_list_request.py +94 -0
- rankrush_client/models/buzz_research_list_response.py +101 -0
- rankrush_client/models/buzz_visibility_score_response.py +125 -0
- rankrush_client/models/buzz_visibility_score_response_engagement_metrics.py +96 -0
- rankrush_client/models/buzz_visibility_score_response_top_subreddits_inner.py +94 -0
- rankrush_client/models/buzz_wait_request.py +90 -0
- rankrush_client/models/cap_error.py +108 -0
- rankrush_client/models/check_ai_visibility_request.py +112 -0
- rankrush_client/models/check_ai_visibility_response.py +114 -0
- rankrush_client/models/check_ai_visibility_response_dropped_inner.py +92 -0
- rankrush_client/models/create_lead_account_request.py +105 -0
- rankrush_client/models/create_lead_account_response.py +109 -0
- rankrush_client/models/create_lead_account_response_scan.py +99 -0
- rankrush_client/models/delete_audit_run_request.py +88 -0
- rankrush_client/models/delete_audit_run_response.py +101 -0
- rankrush_client/models/delete_audit_run_response_deleted.py +92 -0
- rankrush_client/models/error.py +102 -0
- rankrush_client/models/extract_offerings_request.py +90 -0
- rankrush_client/models/extract_offerings_response.py +108 -0
- rankrush_client/models/generate_blog_post_request.py +93 -0
- rankrush_client/models/generate_blog_post_response.py +98 -0
- rankrush_client/models/generate_blog_topics_request.py +93 -0
- rankrush_client/models/generate_content_strategy_response.py +97 -0
- rankrush_client/models/generate_node_content_request.py +133 -0
- rankrush_client/models/generate_node_content_response.py +101 -0
- rankrush_client/models/generate_node_content_response_content.py +143 -0
- rankrush_client/models/generate_visibility_keywords_response.py +115 -0
- rankrush_client/models/generate_visibility_keywords_response_keywords_inner.py +100 -0
- rankrush_client/models/generate_visibility_keywords_response_sources.py +90 -0
- rankrush_client/models/gsc_fetch_queries_response.py +105 -0
- rankrush_client/models/gsc_fetch_queries_response_queries_inner.py +96 -0
- rankrush_client/models/gsc_select_property_request.py +90 -0
- rankrush_client/models/job_queued_response.py +109 -0
- rankrush_client/models/keywords_list_request.py +92 -0
- rankrush_client/models/keywords_list_response.py +93 -0
- rankrush_client/models/lead_enqueue_request.py +98 -0
- rankrush_client/models/lead_enqueue_response.py +114 -0
- rankrush_client/models/lead_enqueue_result.py +103 -0
- rankrush_client/models/lead_item.py +110 -0
- rankrush_client/models/node_complete_request.py +92 -0
- rankrush_client/models/node_complete_response.py +108 -0
- rankrush_client/models/node_progress.py +115 -0
- rankrush_client/models/node_progress_summary.py +97 -0
- rankrush_client/models/nodes_list_request.py +104 -0
- rankrush_client/models/nodes_list_response.py +101 -0
- rankrush_client/models/ok_response.py +95 -0
- rankrush_client/models/profile.py +122 -0
- rankrush_client/models/profile_id_camel_request.py +88 -0
- rankrush_client/models/profile_id_snake_request.py +88 -0
- rankrush_client/models/profile_update_request.py +104 -0
- rankrush_client/models/profiles_list_request.py +89 -0
- rankrush_client/models/rate_limit_error.py +112 -0
- rankrush_client/models/report_job_get_request.py +89 -0
- rankrush_client/models/report_job_get_response.py +142 -0
- rankrush_client/models/save_cms_connection_request.py +96 -0
- rankrush_client/models/save_cms_connection_response.py +99 -0
- rankrush_client/models/suggest_competitors_request.py +118 -0
- rankrush_client/models/suggest_competitors_response.py +107 -0
- rankrush_client/models/suggest_competitors_response_category.py +96 -0
- rankrush_client/models/suggest_competitors_response_competitors_inner.py +102 -0
- rankrush_client/models/validation_error_body.py +102 -0
- rankrush_client/models/verify_blog_posts_request.py +90 -0
- rankrush_client/models/verify_blog_posts_response.py +98 -0
- rankrush_client/py.typed +0 -0
- rankrush_client/rest.py +263 -0
rankrush/__init__.py
ADDED
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
|