recala-cli 0.6.0__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 (67) hide show
  1. recala_cli/__init__.py +3 -0
  2. recala_cli/__main__.py +3 -0
  3. recala_cli/article_runs.py +372 -0
  4. recala_cli/auth.py +196 -0
  5. recala_cli/client.py +103 -0
  6. recala_cli/commands/__init__.py +1 -0
  7. recala_cli/commands/article.py +835 -0
  8. recala_cli/commands/auth.py +207 -0
  9. recala_cli/commands/billing.py +76 -0
  10. recala_cli/commands/bing.py +164 -0
  11. recala_cli/commands/cadence.py +217 -0
  12. recala_cli/commands/connect.py +395 -0
  13. recala_cli/commands/credits.py +46 -0
  14. recala_cli/commands/faithful_translations.py +70 -0
  15. recala_cli/commands/gsc.py +79 -0
  16. recala_cli/commands/index.py +293 -0
  17. recala_cli/commands/keys.py +98 -0
  18. recala_cli/commands/knowledge.py +245 -0
  19. recala_cli/commands/languages.py +119 -0
  20. recala_cli/commands/monthly_report.py +152 -0
  21. recala_cli/commands/notifications.py +106 -0
  22. recala_cli/commands/onboard.py +237 -0
  23. recala_cli/commands/org.py +352 -0
  24. recala_cli/commands/profile.py +70 -0
  25. recala_cli/commands/questions.py +246 -0
  26. recala_cli/commands/reads.py +320 -0
  27. recala_cli/commands/refresh.py +138 -0
  28. recala_cli/commands/refusals.py +20 -0
  29. recala_cli/commands/schema.py +47 -0
  30. recala_cli/commands/shell.py +81 -0
  31. recala_cli/commands/skills.py +92 -0
  32. recala_cli/commands/slot_topic.py +94 -0
  33. recala_cli/commands/status.py +153 -0
  34. recala_cli/commands/topics.py +328 -0
  35. recala_cli/config.py +176 -0
  36. recala_cli/confirm.py +137 -0
  37. recala_cli/display.py +40 -0
  38. recala_cli/endpoints.py +258 -0
  39. recala_cli/errors.py +220 -0
  40. recala_cli/human.py +324 -0
  41. recala_cli/ids.py +139 -0
  42. recala_cli/labels.py +74 -0
  43. recala_cli/main.py +129 -0
  44. recala_cli/onboarding.py +522 -0
  45. recala_cli/organizations.py +197 -0
  46. recala_cli/output.py +34 -0
  47. recala_cli/parallel.py +18 -0
  48. recala_cli/polling.py +102 -0
  49. recala_cli/prompts.py +88 -0
  50. recala_cli/runtime.py +216 -0
  51. recala_cli/search_numbers.py +22 -0
  52. recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
  53. recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
  54. recala_cli/skills/recala-cli-contract.md +78 -0
  55. recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
  56. recala_cli/skills/recala-onboard/SKILL.md +25 -0
  57. recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
  58. recala_cli/skills/recala-plan-month/SKILL.md +33 -0
  59. recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
  60. recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
  61. recala_cli/ui.py +260 -0
  62. recala_cli/updates.py +43 -0
  63. recala_cli/views.py +372 -0
  64. recala_cli-0.6.0.dist-info/METADATA +63 -0
  65. recala_cli-0.6.0.dist-info/RECORD +67 -0
  66. recala_cli-0.6.0.dist-info/WHEEL +4 -0
  67. recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,395 @@
1
+ """`recala connect`: where articles are published (the CMS) and how they are measured (Search Console).
2
+
3
+ Secrets never travel as arguments: a CMS token or an application password comes
4
+ from an environment variable, stdin or a hidden prompt and goes only into the request
5
+ body sent to Recala, which stores it encrypted. Search Console is connected with
6
+ Google's own sign-in in the browser (the CLI opens the link and waits), then the
7
+ property is chosen here; Recala never sees a Google password.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+
14
+ import os
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ import click
19
+
20
+ from recala_cli import config
21
+ from recala_cli.client import RecalaClient
22
+ from recala_cli.endpoints import (
23
+ CMS_STATUS,
24
+ GSC_PROPERTIES,
25
+ GSC_PROPERTY,
26
+ GSC_SERVICE_ACCOUNT,
27
+ GSC_SERVICE_ACCOUNT_CHECK,
28
+ GSC_STATUS,
29
+ ORG_SHOW,
30
+ PEOPLE_LIST,
31
+ SANITY_CONNECT,
32
+ SANITY_STATUS,
33
+ INDEXING_SITEMAPS,
34
+ WORDPRESS_CONNECT,
35
+ WORDPRESS_VERIFY,
36
+ )
37
+ from recala_cli.confirm import is_interactive
38
+ from recala_cli.errors import CliError, ExitCode
39
+ from recala_cli.polling import BROWSER_POLL_SECONDS, open_in_browser, poll_until
40
+ from recala_cli.prompts import ask, ask_optional, ask_secret, choose
41
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, sanity_or_none, accepted_yes_option, dry_run_option
42
+ from recala_cli.views import company_timezone, connection_lines, sitemap_lines
43
+
44
+ GSC_WAIT_SECONDS = 600
45
+ CMS_WAIT_SECONDS = 600
46
+ _GSC_CONNECTED = {"backfilling", "waiting_for_data", "live", "stale"}
47
+ SANITY_TOKEN_ENV = "RECALA_SANITY_TOKEN"
48
+ SANITY_HOOK_ENV = "RECALA_SANITY_BUILD_HOOK"
49
+ WP_PASSWORD_ENV = "RECALA_WP_APP_PASSWORD"
50
+ SERVICE_ACCOUNT_OFF = ("The service-account method is not enabled on this Recala server. Ask Recala support to enable "
51
+ "it, or use: recala connect gsc --oauth")
52
+
53
+
54
+ def organization(client: RecalaClient) -> dict[str, Any]:
55
+ shown = client.call(ORG_SHOW, path={"org_id": str(client.org_id)}) or {}
56
+ return dict(shown.get("org") or {})
57
+
58
+
59
+ def read_connections(client: RecalaClient, *, with_sitemap: bool = True) -> dict[str, Any]:
60
+ status = {"cms": optional_read(client, CMS_STATUS), "search_console": optional_read(client, GSC_STATUS),
61
+ "org": organization(client)}
62
+ if (status["cms"] or {}).get("provider") == "Sanity":
63
+ status["sanity"] = sanity_or_none(client)
64
+ if with_sitemap:
65
+ status["sitemap"] = _sitemap_or_none(client)
66
+ return status
67
+
68
+
69
+ def _sitemap_or_none(client: RecalaClient) -> dict[str, Any] | None:
70
+ """Sitemaps live in Search Console: without a connection (409) there is nothing to show, not a failure."""
71
+ try:
72
+ return optional_read(client, INDEXING_SITEMAPS) # type: ignore[no-any-return]
73
+ except CliError as error:
74
+ if error.exit_code == ExitCode.CONFLICT:
75
+ return None
76
+ raise
77
+
78
+
79
+ @click.group()
80
+ def connect() -> None:
81
+ """Connections: the company's CMS and Google Search Console."""
82
+
83
+
84
+ @connect.command("status")
85
+ @common_options
86
+ @command_runner(contextual_human=lambda ctx, s: "\n".join(connection_lines(s, zone=company_timezone(ctx, s))))
87
+ def status(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
88
+ """CMS, Search Console and sitemap: next steps.
89
+
90
+ The CMS (its endpoint and the public site), Search Console and the sitemap, with the next step for each."""
91
+ return read_connections(open_client(ctx, org_flag))
92
+
93
+
94
+ # ── Sanity ──────────────────────────────────────────────────────────────────────
95
+
96
+
97
+ def suggested_blog(org: dict[str, Any]) -> str | None:
98
+ """The blog the website scan found, else the website's /blog."""
99
+ if org.get("blog_base_url"):
100
+ return str(org["blog_base_url"]).rstrip("/")
101
+ website = str(org.get("website_url") or "").rstrip("/")
102
+ return f"{website}/blog" if website else None
103
+
104
+
105
+ def sanity_author_map(ctx: click.Context, client: RecalaClient, pairs: tuple[str, ...]) -> dict[str, str]:
106
+ """``PERSON=SANITY_AUTHOR_ID`` pairs, else ask for each author's Sanity author document ID."""
107
+ if pairs:
108
+ mapping = dict(pair.split("=", 1) for pair in pairs if "=" in pair)
109
+ if len(mapping) != len(pairs):
110
+ raise CliError(ExitCode.USAGE, "invalid_author", "--author takes PERSON_ID=SANITY_AUTHOR_ID.")
111
+ return mapping
112
+ people = (client.call(PEOPLE_LIST) or {}).get("people") or []
113
+ if not people:
114
+ raise CliError(ExitCode.CONFLICT, "no_authors", "Add an author first: recala authors add --name ... --title ...")
115
+ mapping = {}
116
+ for person in people:
117
+ answer = ask_optional(ctx, None, label=f"Sanity author document ID for {person.get('display_name')}")
118
+ if answer:
119
+ mapping[str(person["id"])] = answer
120
+ if not mapping:
121
+ raise CliError(ExitCode.USAGE, "missing_input",
122
+ "Map at least one author: --author PERSON_ID=SANITY_AUTHOR_ID (the author's document ID in Studio).")
123
+ return mapping
124
+
125
+
126
+ @connect.command("sanity")
127
+ @common_options
128
+ @command_runner(human=lambda r: f"Sanity connected: project {r.get('project_id')}, dataset {r.get('dataset')}.")
129
+ @click.option("--project", "project_id", default=None, help="Sanity project ID.")
130
+ @click.option("--dataset", default=None, help="Dataset (default production).")
131
+ @click.option("--schema-profile", type=click.Choice(["post", "article"]), default=None,
132
+ help="Sanity document schema (default post; prompts when interactive).")
133
+ @click.option("--blog-url", default=None, help="The public HTTPS address where articles appear.")
134
+ @click.option("--author", "authors", multiple=True, help="PERSON_ID=SANITY_AUTHOR_ID. Repeat for each author.")
135
+ @click.option("--token-stdin", is_flag=True, help="Read the CMS secret from one stdin line; never echoes it.")
136
+ @accepted_yes_option
137
+ @dry_run_option
138
+ def sanity(ctx: click.Context, org_flag: str | None, project_id: str | None, dataset: str | None, blog_url: str | None,
139
+ authors: tuple[str, ...], token_stdin: bool, schema_profile: str | None) -> dict[str, Any]:
140
+ """Publish to Sanity. The write token comes from RECALA_SANITY_TOKEN, --token-stdin or a hidden prompt."""
141
+ return connect_sanity(ctx, open_client(ctx, org_flag), project_id=project_id, dataset=dataset, blog_url=blog_url,
142
+ authors=authors, token_stdin=token_stdin, schema_profile=schema_profile)
143
+
144
+
145
+ def connect_sanity(ctx: click.Context, client: RecalaClient, *, project_id: str | None = None,
146
+ dataset: str | None = None, blog_url: str | None = None,
147
+ authors: tuple[str, ...] = (), token_stdin: bool = False,
148
+ schema_profile: str | None = None) -> dict[str, Any]:
149
+ org = organization(client)
150
+ body: dict[str, Any] = {
151
+ "project_id": ask(ctx, project_id, label="Sanity project ID", flag="--project"),
152
+ "dataset": ask(ctx, dataset, label="Dataset", flag="--dataset", default="production"),
153
+ "blog_url": ask(ctx, blog_url, label="Public blog address (https)", flag="--blog-url",
154
+ default=suggested_blog(org) if is_interactive(ctx) and not blog_url else None),
155
+ "author_map": sanity_author_map(ctx, client, authors),
156
+ "schema_profile": choose(ctx, [("post", "post: documents with a status field"), ("article", "article: documents without a status field")],
157
+ label="Schema profile", flag="--schema-profile", value=schema_profile,
158
+ default="post"),
159
+ }
160
+ token = ask_secret(ctx, label="Sanity write token", env=SANITY_TOKEN_ENV, token_stdin=token_stdin)
161
+ hook = ask_secret_optional(SANITY_HOOK_ENV)
162
+ confirm(ctx, client, f"Connect Sanity project {body['project_id']} ({body['dataset']}) to publish on "
163
+ f"{body['blog_url']}?")
164
+ connected: dict[str, Any] = client.call(SANITY_CONNECT, json={**body, "token": token,
165
+ **({"build_hook_url": hook} if hook else {})})
166
+ return {**connected, "schema_profile": body["schema_profile"]}
167
+
168
+
169
+ def ask_secret_optional(env: str) -> str:
170
+ """An optional secret: only ever from the environment."""
171
+ return os.environ.get(env, "").strip()
172
+
173
+
174
+ # ── WordPress ───────────────────────────────────────────────────────────────────
175
+
176
+
177
+ def _wordpress_com_address(ctx: click.Context, site: str, wpcom_address: str | None) -> str:
178
+ """A WordPress.com site behind its own domain (halconia.es) connects through its *.wordpress.com address."""
179
+ if ".wordpress.com" in site:
180
+ return site
181
+ return ask(ctx, wpcom_address, label=f"The WordPress.com address of {site} (e.g. yoursite.wordpress.com)",
182
+ flag="--wpcom-address")
183
+
184
+
185
+ @connect.command("wordpress")
186
+ @common_options
187
+ @command_runner(human=lambda r: f"WordPress connected ({r.get('mode')}): {r.get('site_url') or r.get('blog_url')}.")
188
+ @click.option("--site", default=None, help="The WordPress site address.")
189
+ @click.option("--user", "user_login", default=None, help="Self-hosted: the WordPress user that publishes.")
190
+ @click.option("--wordpress-com", "wordpress_com", is_flag=True, help="The site is hosted on WordPress.com "
191
+ "(also behind your own domain).")
192
+ @click.option("--wpcom-address", default=None, help="With --wordpress-com and your own domain: the *.wordpress.com address.")
193
+ @click.option("--token-stdin", is_flag=True, help="Read the application password from one stdin line; never echoes it.")
194
+ @accepted_yes_option
195
+ @dry_run_option
196
+ def wordpress(ctx: click.Context, org_flag: str | None, site: str | None, user_login: str | None, wordpress_com: bool,
197
+ wpcom_address: str | None, token_stdin: bool) -> dict[str, Any]:
198
+ """Publish to WordPress.
199
+
200
+ Self-hosted with an application password
201
+ (RECALA_WP_APP_PASSWORD, --token-stdin or a hidden prompt),
202
+ or WordPress.com with its sign-in in the browser."""
203
+ return connect_wordpress(ctx, open_client(ctx, org_flag), site=site, user_login=user_login,
204
+ wordpress_com=wordpress_com, wpcom_address=wpcom_address, token_stdin=token_stdin)
205
+
206
+
207
+ def connect_wordpress(ctx: click.Context, client: RecalaClient, *, site: str | None = None,
208
+ user_login: str | None = None, wordpress_com: bool = False,
209
+ wpcom_address: str | None = None, token_stdin: bool = False) -> dict[str, Any]:
210
+ org = organization(client)
211
+ address = ask(ctx, site, label="WordPress site address", flag="--site", default=org.get("website_url") or None)
212
+ if wordpress_com:
213
+ address = _wordpress_com_address(ctx, address, wpcom_address)
214
+ confirm(ctx, client, f"Connect the WordPress site {address}?")
215
+ verified = client.call(WORDPRESS_VERIFY, json={"url": address, "return_path": "/portal/settings"}) or {}
216
+ if not verified.get("available"):
217
+ hint = (" If it is a WordPress.com site behind your own domain, run: recala connect wordpress --wordpress-com"
218
+ if not wordpress_com else "")
219
+ raise CliError(ExitCode.CONFLICT, "wordpress_unavailable", f"{verified.get('error') or 'Not available.'}{hint}")
220
+ if verified.get("mode") == "wpcom":
221
+ return _connect_wordpress_com(ctx, client, verified, public_site=org.get("website_url") or "")
222
+ login = ask(ctx, user_login, label="WordPress user", flag="--user")
223
+ password = ask_secret(ctx, label="Application password (WordPress → Users → Profile)",
224
+ env=WP_PASSWORD_ENV, token_stdin=token_stdin)
225
+ connected: dict[str, Any] = client.call(WORDPRESS_CONNECT, json={
226
+ "site_url": address, "user_login": login, "password": password,
227
+ "return_context": verified.get("return_context")})
228
+ return connected
229
+
230
+
231
+ def _connect_wordpress_com(ctx: click.Context, client: RecalaClient, verified: dict[str, Any], *,
232
+ public_site: str) -> dict[str, Any]:
233
+ open_in_browser(ctx, verified["auth_url"], why="Approve Recala on WordPress.com in your browser:")
234
+ cms = poll_until(ctx, lambda: client.call(CMS_STATUS) or {}, lambda c: bool(c.get("connected")),
235
+ label="Waiting for WordPress.com approval in your browser", timeout_seconds=CMS_WAIT_SECONDS,
236
+ interval_seconds=BROWSER_POLL_SECONDS, outside_ticks=False,
237
+ done=lambda c: f"WordPress.com connected: {c.get('site_url')}")
238
+ return {"mode": "wpcom", "blog_url": cms.get("site_url"), "public_site": public_site, "connected": True}
239
+
240
+
241
+ # ── Search Console ──────────────────────────────────────────────────────────────
242
+
243
+
244
+ def suggested_property(properties: list[dict[str, Any]], website: str) -> str | None:
245
+ """The domain property for the company's own site first, then a URL-prefix property of it."""
246
+ host = (urlsplit(website if "//" in website else f"https://{website}").hostname or "").removeprefix("www.")
247
+ if not host:
248
+ return None
249
+ ranked = [p["site_url"] for p in properties if p.get("site_url") == f"sc-domain:{host}"]
250
+ ranked += [p["site_url"] for p in properties
251
+ if (urlsplit(p.get("site_url", "")).hostname or "").removeprefix("www.") == host]
252
+ return ranked[0] if ranked else None
253
+
254
+
255
+ def connect_search_console(ctx: click.Context, client: RecalaClient, *, property_choice: str | None,
256
+ force_oauth: bool = False) -> dict[str, Any]:
257
+ """Google sign-in in the browser (if needed), then the property, chosen here."""
258
+ current = client.call(GSC_STATUS) or {}
259
+ if force_oauth or current.get("state") in ("not_connected", "needs_reconnect", None):
260
+ link = f"{config.web_url()}/portal/settings?gsc=connect&org={client.org_id}"
261
+ open_in_browser(ctx, link, why="Sign in with Google in your browser to connect Search Console:")
262
+ current = poll_until(ctx, lambda: client.call(GSC_STATUS) or {},
263
+ lambda s: s.get("state") not in ("not_connected", "needs_reconnect", None),
264
+ label="Waiting for Google sign-in in your browser", timeout_seconds=GSC_WAIT_SECONDS,
265
+ interval_seconds=BROWSER_POLL_SECONDS, outside_ticks=False,
266
+ done=lambda s: "Google account connected")
267
+ if current.get("state") == "connecting" or (property_choice and property_choice != current.get("property")):
268
+ current = choose_property(ctx, client, property_choice)
269
+ return current
270
+
271
+
272
+ def choose_property(ctx: click.Context, client: RecalaClient, property_choice: str | None) -> dict[str, Any]:
273
+ listed = client.call(GSC_PROPERTIES) or {}
274
+ properties = listed.get("properties") or []
275
+ if not properties:
276
+ raise CliError(ExitCode.CONFLICT, "no_properties",
277
+ "This Google account has no verified Search Console property. Verify the site in Search Console, "
278
+ "then run: recala connect gsc")
279
+ website = organization(client).get("website_url") or ""
280
+ chosen = choose(ctx, [(p["site_url"], f"{p['site_url']} ({p.get('permission_level')})") for p in properties],
281
+ label="Which Search Console property is this company's site?", flag="--property",
282
+ value=property_choice,
283
+ default=listed.get("suggested_property") or suggested_property(properties, website))
284
+ confirm(ctx, client, f"Use the Search Console property {chosen}?")
285
+ client.call(GSC_PROPERTY, json={"property": chosen})
286
+ answer: dict[str, Any] = client.call(GSC_STATUS) or {}
287
+ return answer
288
+
289
+
290
+ def _describe_gsc(ctx: click.Context, result: dict[str, Any]) -> str:
291
+ zone = company_timezone(ctx, result)
292
+ lines = connection_lines({"search_console": result.get("search_console"), "cms": result.get("cms"),
293
+ "org": result.get("org")}, zone=zone)[-2:] if result.get("search_console") else []
294
+ if result.get("service_account"):
295
+ account = result["service_account"]
296
+ lines = [account.get("instruction") or f"Add {account.get('email')} as a Restricted user."]
297
+ if result.get("sitemap"):
298
+ lines.extend(sitemap_lines(result["sitemap"], zone))
299
+ return "\n".join(lines) or str(result)
300
+
301
+
302
+ @connect.command("gsc")
303
+ @common_options
304
+ @command_runner(contextual_human=_describe_gsc)
305
+ @click.option("--oauth", "force_oauth", is_flag=True, help="Sign in with Google in the browser (the default path).")
306
+ @click.option("--property", "property_choice", default=None, help="The property to use, e.g. sc-domain:example.com.")
307
+ @click.option("--service-account", "how", is_flag=True, help="Instead: the email to add as a Restricted user.")
308
+ @click.option("--check", "prop", default=None, help="With the service account: connect this property once it was added.")
309
+ @accepted_yes_option
310
+ @dry_run_option
311
+ def gsc(ctx: click.Context, org_flag: str | None, force_oauth: bool, property_choice: str | None, how: bool,
312
+ prop: str | None) -> dict[str, Any]:
313
+ """Connect Google Search Console.
314
+
315
+ Google sign-in in the browser, then choose the property."""
316
+ if how and prop:
317
+ raise click.UsageError("Choose --service-account or --check <property>.")
318
+ client = open_client(ctx, org_flag)
319
+ if how:
320
+ account = _service_account(client)
321
+ if account is not None:
322
+ return {"service_account": account}
323
+ if not is_interactive(ctx) or not click.confirm(f" {SERVICE_ACCOUNT_OFF}\n Connect with Google sign-in now?",
324
+ default=True, err=True):
325
+ raise CliError(ExitCode.CONFLICT, "service_account_not_configured", SERVICE_ACCOUNT_OFF, 503)
326
+ force_oauth = True
327
+ if prop:
328
+ confirm(ctx, client, f"Connect {prop} through Recala's service account?")
329
+ checked: dict[str, Any] = client.call(GSC_SERVICE_ACCOUNT_CHECK, json={"property": prop})
330
+ return {"search_console": checked}
331
+ connected = connect_search_console(ctx, client, property_choice=property_choice, force_oauth=force_oauth)
332
+ return {"search_console": connected}
333
+
334
+
335
+ def _service_account(client: RecalaClient) -> dict[str, Any] | None:
336
+ """The service account's email, or None when the server has no service account configured (a 503)."""
337
+ try:
338
+ answer: dict[str, Any] = client.call(GSC_SERVICE_ACCOUNT)
339
+ return answer
340
+ except CliError as error:
341
+ if error.status != 503:
342
+ raise
343
+ return None
344
+
345
+
346
+ @connect.command("sitemap", hidden=True) # same view as `recala index sitemaps`
347
+ @common_options
348
+ @command_runner(contextual_human=lambda ctx, r: "\n".join(sitemap_lines(r, company_timezone(ctx, r))))
349
+ def sitemap(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
350
+ """The site's sitemaps in Search Console.
351
+
352
+ Health, errors and last read."""
353
+ answer: dict[str, Any] = open_client(ctx, org_flag).call(INDEXING_SITEMAPS)
354
+ return answer
355
+
356
+
357
+ @click.group()
358
+ def cms() -> None:
359
+ """The CMS where the company publishes."""
360
+
361
+
362
+ @cms.command("connect")
363
+ @common_options
364
+ @command_runner(human=lambda r: f"CMS connected: {r.get('provider') or r.get('cms_type') or r.get('mode') or 'Sanity'}.")
365
+ @click.option("--provider", type=click.Choice(["sanity", "wordpress"]), default=None)
366
+ @click.option("--project", "project_id", default=None)
367
+ @click.option("--dataset", default=None)
368
+ @click.option("--schema-profile", type=click.Choice(["post", "article"]), default=None,
369
+ help="Sanity document schema (default post; prompts when interactive).")
370
+ @click.option("--blog-url", default=None)
371
+ @click.option("--author", "authors", multiple=True, help="PERSON_ID=SANITY_AUTHOR_ID.")
372
+ @click.option("--site", default=None)
373
+ @click.option("--user", "user_login", default=None)
374
+ @click.option("--wordpress-com", is_flag=True)
375
+ @click.option("--wpcom-address", default=None)
376
+ @click.option("--token-stdin", is_flag=True, help="Read the CMS token/password from one stdin line; never echoes it.")
377
+ @accepted_yes_option
378
+ @dry_run_option
379
+ def connect_cms(ctx: click.Context, org_flag: str | None, provider: str | None, project_id: str | None,
380
+ dataset: str | None, blog_url: str | None, authors: tuple[str, ...], site: str | None,
381
+ user_login: str | None, wordpress_com: bool, wpcom_address: str | None, token_stdin: bool,
382
+ schema_profile: str | None) -> dict[str, Any]:
383
+ """Connect Sanity or WordPress. Credentials come from env, --token-stdin or a hidden human prompt."""
384
+ client = open_client(ctx, org_flag)
385
+ provider = choose(ctx, [("sanity", "Sanity"), ("wordpress", "WordPress")], label="CMS provider",
386
+ flag="--provider", value=provider)
387
+ if provider == "sanity":
388
+ return connect_sanity(ctx, client, project_id=project_id, dataset=dataset, blog_url=blog_url,
389
+ authors=authors, token_stdin=token_stdin, schema_profile=schema_profile)
390
+ return connect_wordpress(ctx, client, site=site, user_login=user_login, wordpress_com=wordpress_com,
391
+ wpcom_address=wpcom_address, token_stdin=token_stdin)
392
+
393
+
394
+ cms.add_command(copy.copy(status), name="status") # same view as `recala connect status`
395
+ cms.commands["status"].hidden = True
@@ -0,0 +1,46 @@
1
+ """`recala credits`: everything that limits spending, before any paid action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from recala_cli.client import RecalaClient
10
+ from recala_cli.endpoints import (
11
+ BILLING_CREDITS,
12
+ BILLING_MANAGED_USAGE,
13
+ BILLING_SPEND,
14
+ BILLING_SUBSCRIPTION,
15
+ CREDITS_SUMMARY,
16
+ ENTITLEMENTS,
17
+ )
18
+ from recala_cli.runtime import command_runner, common_options, open_client, optional_read
19
+ from recala_cli.views import describe_credits
20
+
21
+
22
+ def read_credits(client: RecalaClient) -> dict[str, Any]:
23
+ """The balance, the plan, the managed cap, the portal's summary (plan, left, reset) and the plan's caps."""
24
+ entitlements = optional_read(client, ENTITLEMENTS)
25
+ return {
26
+ "credits": client.call(BILLING_CREDITS),
27
+ "subscription": client.call(BILLING_SUBSCRIPTION),
28
+ "managed_usage": client.call(BILLING_MANAGED_USAGE),
29
+ "summary": optional_read(client, CREDITS_SUMMARY),
30
+ "entitlements": entitlements if (entitlements or {}).get("enabled") else None,
31
+ }
32
+
33
+
34
+ @click.command()
35
+ @common_options
36
+ @command_runner(human=describe_credits)
37
+ @click.option("--spend", is_flag=True, help="Also Recala's provider spend this month and today (owners).")
38
+ def credits(ctx: click.Context, org_flag: str | None, spend: bool) -> dict[str, Any]:
39
+ """Plan, articles left, reset date, extras, caps.
40
+
41
+ Unlimited when no plan caps apply."""
42
+ client = open_client(ctx, org_flag)
43
+ answer = read_credits(client)
44
+ if spend:
45
+ answer["provider_spend"] = client.call(BILLING_SPEND)
46
+ return answer
@@ -0,0 +1,70 @@
1
+ """Explicit owner consent for automatic approval of verified faithful translations."""
2
+
3
+ import click
4
+
5
+ from recala_cli import auth
6
+ from recala_cli.endpoints import FAITHFUL_TRANSLATIONS_SET, FAITHFUL_TRANSLATIONS_SHOW
7
+ from recala_cli.errors import CliError, ExitCode
8
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option
9
+
10
+ SETTING = "auto_approve_faithful_translations"
11
+
12
+
13
+ def _owner_session_client(ctx, org_flag):
14
+ if auth.credential_kind() == "api_key":
15
+ raise CliError(
16
+ ExitCode.FORBIDDEN, "human_only",
17
+ "This setting requires a signed-in company owner. Run: recala login",
18
+ )
19
+ return open_client(ctx, org_flag)
20
+
21
+
22
+ def _describe_setting(result):
23
+ state = "on" if result[SETTING] else "off"
24
+ prefix = "Dry run — " if result.get("dry_run") else ""
25
+ return f"{prefix}Faithful translation auto-approval: {state}"
26
+
27
+
28
+ @click.group("faithful-translations")
29
+ def faithful_translations():
30
+ """Auto-approve verified translations (owners).
31
+
32
+ Ordinary auto-approve is also required."""
33
+
34
+
35
+ @faithful_translations.command("show")
36
+ @common_options
37
+ @command_runner(human=_describe_setting)
38
+ def show(ctx, org_flag):
39
+ """Show the saved faithful-translation approval setting."""
40
+ return _owner_session_client(ctx, org_flag).call(FAITHFUL_TRANSLATIONS_SHOW)
41
+
42
+
43
+ def _change_setting(ctx, org_flag, enabled, dry_run):
44
+ client = _owner_session_client(ctx, org_flag)
45
+ if dry_run:
46
+ return {"dry_run": True, SETTING: enabled}
47
+ confirm(ctx, client, f"Turn faithful translation auto-approval {'on' if enabled else 'off'}?")
48
+ return client.call(FAITHFUL_TRANSLATIONS_SET, json={"enabled": enabled})
49
+
50
+
51
+ @faithful_translations.command("enable")
52
+ @common_options
53
+ @command_runner(human=_describe_setting)
54
+ @click.option("--dry-run", is_flag=True, help="Preview the setting without saving.")
55
+ @accepted_yes_option
56
+ def enable(ctx, org_flag, dry_run):
57
+ """Opt into auto-approving verified translations.
58
+
59
+ Approval happens after the source and translation checks pass."""
60
+ return _change_setting(ctx, org_flag, True, dry_run)
61
+
62
+
63
+ @faithful_translations.command("disable")
64
+ @common_options
65
+ @command_runner(human=_describe_setting)
66
+ @click.option("--dry-run", is_flag=True, help="Preview the setting without saving.")
67
+ @accepted_yes_option
68
+ def disable(ctx, org_flag, dry_run):
69
+ """Turn off the faithful-translation approval opt-in."""
70
+ return _change_setting(ctx, org_flag, False, dry_run)
@@ -0,0 +1,79 @@
1
+ """Google Search Console: read the stored numbers (connect with `recala connect gsc`).
2
+
3
+ The numbers come from what Recala has stored, never from a live call to Google, so exploring them costs nothing. Only
4
+ Google's final days are counted. A company with no numbers gets its state in words, never a zero.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ from recala_cli.endpoints import GSC_PERFORMANCE
14
+ from recala_cli.errors import CliError, ExitCode
15
+ from recala_cli.runtime import command_runner, common_options, open_client
16
+ from recala_cli.search_numbers import ranked_lines, totals_sentence
17
+
18
+ SWITCHED_OFF = "Google Search Console isn't switched on for this company yet."
19
+ #: Like `recala bing data`, these fail: there is a step for the person to take.
20
+ _NEEDS_A_STEP = {
21
+ "not_connected": ("gsc_not_connected", "Google Search Console isn't connected for this company. Connect it: recala connect gsc"),
22
+ "connecting": ("gsc_not_connected", "Signed in to Google. Choose the property: recala connect gsc"),
23
+ "needs_reconnect": ("gsc_needs_reconnect", "Google access was lost. Reconnect: recala connect gsc"),
24
+ }
25
+ _NO_NUMBERS_YET = {
26
+ "backfilling": "{site}: first import running.",
27
+ "pending_review": "{site}: Recala is checking that this property is the company's. Numbers appear once that is done.",
28
+ "waiting_for_data": "{site}: connected, and Google has no search data for it in this period.",
29
+ }
30
+
31
+
32
+ @click.group()
33
+ def gsc() -> None:
34
+ """Google Search Console: its search numbers."""
35
+
36
+
37
+ def _this_period(block: dict[str, Any] | None) -> list[dict[str, Any]]:
38
+ """A key with no impressions in this period is absent from it, not a result of zero."""
39
+ rows = (block or {}).get("rows") or []
40
+ return [{"key": row["key"], **row["current"]} for row in rows if row["current"]["impressions"]]
41
+
42
+
43
+ def _describe_performance(data: dict[str, Any]) -> str:
44
+ source = data.get("source") or {}
45
+ site = source.get("property")
46
+ totals = data.get("totals")
47
+ if not totals:
48
+ fallback = "{site}: no Search Console numbers stored for this period yet."
49
+ return _NO_NUMBERS_YET.get(source.get("state") or "", fallback).format(site=site)
50
+ queries = data.get("queries") or {}
51
+ period = (queries.get("window") or {}).get("current") or {}
52
+ lines = [f"{site}, {period.get('start')} to {period.get('end')}",
53
+ f"{totals_sentence(totals)}. Final data through {source.get('final_through')}."]
54
+ if source.get("stale_since"):
55
+ lines.append(f"Updates stopped on {source['stale_since']}.")
56
+ lines += ranked_lines("Top queries", _this_period(queries)) + ranked_lines("Top pages", _this_period(data.get("pages")))
57
+ hidden = (queries.get("anonymized_share") or {}).get("clicks")
58
+ if hidden:
59
+ lines.append(f"{hidden:.0%} of clicks come from searches Google does not name.")
60
+ return "\n".join(lines)
61
+
62
+
63
+ @gsc.command("data")
64
+ @common_options
65
+ @command_runner(human=_describe_performance)
66
+ @click.option("--days", type=click.IntRange(7, 90), default=28, show_default=True, help="How many days back to read.")
67
+ def data(ctx: click.Context, org_flag: str | None, days: int) -> dict[str, Any]:
68
+ """The stored search numbers: totals, top queries and top pages."""
69
+ client = open_client(ctx, org_flag)
70
+ try:
71
+ answer: dict[str, Any] = client.call(GSC_PERFORMANCE, params={"window_days": days})
72
+ except CliError as error:
73
+ if error.exit_code == ExitCode.NOT_FOUND:
74
+ raise CliError(ExitCode.NOT_FOUND, "gsc_unavailable", SWITCHED_OFF, 404) from error
75
+ raise
76
+ step = _NEEDS_A_STEP.get((answer.get("source") or {}).get("state") or "")
77
+ if step:
78
+ raise CliError(ExitCode.CONFLICT, *step, 409)
79
+ return answer