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,207 @@
1
+ """`recala login`, `recala logout`, `recala whoami` and `recala grant`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from contextlib import AbstractContextManager, nullcontext
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from recala_cli import auth, config, ui
12
+ from recala_cli.auth import credential_kind
13
+ from recala_cli.client import RecalaClient
14
+ from recala_cli.confirm import GRANT_HOURS, grant_status, is_interactive, record_grant, require_current_signin
15
+ from recala_cli.endpoints import ME, SIGN_OUT
16
+ from recala_cli.display import display_name
17
+ from recala_cli.errors import CliError, ExitCode
18
+ from recala_cli.output import notify
19
+ from recala_cli.organizations import (
20
+ ORGANIZATION_LOOKUP_FAILED,
21
+ organization_choices,
22
+ organization_description,
23
+ organization_memberships,
24
+ organization_next_step,
25
+ )
26
+ from recala_cli.runtime import command_runner, common_options, interactive_human_output, json_option, open_client, accepted_yes_option
27
+
28
+
29
+ def _describe_sign_in(result: dict[str, Any]) -> str:
30
+ if not result.get("signed_in"):
31
+ return "Signed out."
32
+ who = result.get("email") or result.get("kind", "token")
33
+ return f"Signed in ({who})."
34
+
35
+
36
+ class SignInScreen:
37
+ """The terminal half of the sign-in: the wordmark, the code, a pulse while the browser half happens."""
38
+
39
+ def __init__(self, *, as_json: bool, within_onboarding: bool = False) -> None:
40
+ self.as_json = as_json
41
+ self.within_onboarding = within_onboarding # the journey drew the wordmark already
42
+ self.style = ui.Style.for_stream()
43
+
44
+ def present_code(self, uri: str, code: str) -> None:
45
+ if self.as_json: # agents and logs: one plain line, before the JSON answer
46
+ notify(f"To sign in, open {uri} and enter the code {code}")
47
+ return
48
+ if not self.within_onboarding:
49
+ ui.banner("Sign in to Recala", style=self.style)
50
+ spaced = " ".join(code)
51
+ notify(f" Your code {self.style.bold(self.style.accent(spaced))}\n")
52
+ notify(f" Open {uri} and enter it there.")
53
+ notify(" New here? Create an account: https://recala.co/register")
54
+ notify(f" {self.style.dim('Only approve a code you just requested. Recala never asks for it by email.')}\n")
55
+
56
+ def waiting(self) -> AbstractContextManager[Any]:
57
+ if self.as_json:
58
+ return nullcontext()
59
+ return ui.Spinner("Waiting for you to approve the code in your browser", style=self.style)
60
+
61
+ def signed_in(self, email: str | None) -> None:
62
+ if self.as_json:
63
+ return
64
+ if self.within_onboarding:
65
+ notify(f"\n {self.style.accent('*')} {self.style.bold('Signed in')} as {display_name(email) or 'you'}\n")
66
+
67
+
68
+ def _describe_login(ctx: click.Context, result: dict[str, Any]) -> str:
69
+ if ctx.params.get("with_token") and not interactive_human_output(ctx):
70
+ return _describe_sign_in(result)
71
+ me = ctx.meta.get("login_identity") or {}
72
+ user = me.get("user") if isinstance(me.get("user"), dict) else {}
73
+ name = display_name(user.get("name") or user.get("display_name") or user.get("email") or result.get("email"))
74
+ lines = [f"Signed in as {name or result.get('kind') or 'you'}."]
75
+ if "login_identity" not in ctx.meta:
76
+ return "\n".join([*lines, ORGANIZATION_LOOKUP_FAILED])
77
+ rows = me.get("memberships") or []
78
+ if len(rows) == 1:
79
+ lines.extend([f"Using {display_name(rows[0].get('name')) or rows[0]['org_id']}.", "Next: recala plan"])
80
+ else:
81
+ lines.extend([organization_choices(rows), organization_next_step(rows)])
82
+ return "\n".join(lines)
83
+
84
+
85
+ def _login_next_step(ctx: click.Context) -> None:
86
+ try:
87
+ client = RecalaClient(ctx.obj["api_url"], transport=ctx.obj.get("transport"))
88
+ client.token = auth.bearer_token(client)
89
+ me = client.call(ME)
90
+ rows = organization_memberships(me, skip_invalid=True)
91
+ except (CliError, ValueError):
92
+ return # successful sign-in stays successful during a presentation outage
93
+ ctx.meta["login_identity"] = {**me, "memberships": rows}
94
+ if len(rows) == 1:
95
+ config.set_active_org(rows[0]["org_id"])
96
+
97
+
98
+ @click.command()
99
+ @json_option
100
+ @command_runner(contextual_human=_describe_login)
101
+ @click.option("--with-token", is_flag=True, help="Read an API key or access token from standard input (for agents and CI).")
102
+ @click.option("--no-browser", is_flag=True, help="Print the sign-in link without opening a browser.")
103
+ def login(ctx: click.Context, with_token: bool, no_browser: bool) -> dict[str, Any]:
104
+ """Sign in with a one-time code in the browser.
105
+
106
+ No account yet? Create one at https://recala.co/register. Agents and CI
107
+ pipe a key with --with-token."""
108
+ if with_token:
109
+ result = auth.store_pasted_token(sys.stdin.readline())
110
+ else:
111
+ result = sign_in(ctx, no_browser=no_browser)
112
+ if not ctx.obj.get("as_json") and (not with_token or interactive_human_output(ctx)):
113
+ _login_next_step(ctx)
114
+ return result
115
+
116
+
117
+ def sign_in(ctx: click.Context, *, no_browser: bool = False, within_onboarding: bool = False) -> dict[str, Any]:
118
+ seams = ctx.find_root().obj
119
+ screen = SignInScreen(as_json=bool(seams.get("as_json")), within_onboarding=within_onboarding)
120
+ client = RecalaClient(seams["api_url"], transport=seams.get("transport"))
121
+ result = auth.device_login(
122
+ client,
123
+ sleep=seams["sleep"],
124
+ open_browser=None if no_browser else seams["open_browser"],
125
+ present_code=screen.present_code,
126
+ waiting=screen.waiting,
127
+ )
128
+ screen.signed_in(result.get("email"))
129
+ return result
130
+
131
+
132
+ @click.command()
133
+ @json_option
134
+ @command_runner(human=_describe_sign_in)
135
+ @accepted_yes_option
136
+ def logout(ctx: click.Context) -> dict[str, Any]:
137
+ """End this machine's session and forget it here.
138
+
139
+ An API key stays valid until revoked."""
140
+ stored = config.load_credentials()
141
+ server_revoked = False
142
+ if stored.get("kind") == "session" and stored.get("access_token"):
143
+ client = RecalaClient(ctx.obj["api_url"], transport=ctx.obj.get("transport"))
144
+ client.token = str(stored["access_token"])
145
+ try:
146
+ client.call(SIGN_OUT)
147
+ server_revoked = True
148
+ except CliError:
149
+ server_revoked = False # forgetting it here is still what the person asked for
150
+ config.forget_credentials()
151
+ return {"signed_in": False, "server_revoked": server_revoked}
152
+
153
+
154
+ def _describe_identity(result: dict[str, Any]) -> str:
155
+ lines = [f"{result['user'].get('email') or result['user'].get('id')} ({result['credential']})"]
156
+ active = result.get("active_org")
157
+ rows = result["memberships"]
158
+ if active:
159
+ selected = next((row for row in rows if row["org_id"] == active), {"org_id": active})
160
+ lines.append(f"Active company: {organization_description(selected)} [{active[:8]}]")
161
+ else:
162
+ lines.extend(["No company selected. Visible companies:", organization_choices(rows),
163
+ organization_next_step(rows)])
164
+ lines.append(f"{len(result['memberships'])} company(ies): recala company list")
165
+ return "\n".join(lines)
166
+
167
+
168
+ @click.command()
169
+ @common_options
170
+ @command_runner(human=_describe_identity)
171
+ def whoami(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
172
+ """Who this credential is and what it may act on.
173
+
174
+ Its companies, and the active one."""
175
+ client = open_client(ctx, org_flag, require_org=False)
176
+ me: dict[str, Any] = client.call(ME)
177
+ return {**me, "credential": credential_kind(), "active_org": ctx.meta.get("resolved_org")}
178
+
179
+
180
+ def _describe_grant(result: dict[str, Any]) -> str:
181
+ if not result.get("active"):
182
+ return "Every write asks you first ([y]es / [n]o / [a]ll)."
183
+ return f"Writes are confirmed for this sign-in until logout or {GRANT_HOURS} hours. Undo: recala grant --revoke"
184
+
185
+
186
+ @click.command()
187
+ @json_option
188
+ @command_runner(human=_describe_grant)
189
+ @click.option("--status", "show_status", is_flag=True, help="Show whether writes are confirmed for this sign-in.")
190
+ @click.option("--revoke", is_flag=True, help="Ask before every write again.")
191
+ def grant(ctx: click.Context, show_status: bool, revoke: bool) -> dict[str, Any]:
192
+ """Confirm every write for this sign-in at once.
193
+
194
+ The [a]ll answer, so an agent can work without prompts."""
195
+ if revoke:
196
+ config.forget_grant()
197
+ return grant_status()
198
+ if show_status:
199
+ return grant_status()
200
+ require_current_signin()
201
+ if not is_interactive(ctx):
202
+ raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required",
203
+ "Only you can give this permission: run `recala grant` in a terminal.")
204
+ if not click.confirm(f" Let writes run without asking, for this sign-in only (until logout or {GRANT_HOURS} hours)?",
205
+ default=False, err=True):
206
+ raise CliError(ExitCode.CONFIRMATION_REQUIRED, "declined", "Nothing changed: every write still asks you.")
207
+ return record_grant()
@@ -0,0 +1,76 @@
1
+ """`recala billing`: payments for the company's plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from datetime import datetime, timezone
10
+
11
+ from recala_cli import config
12
+ from recala_cli.commands.refusals import refusals_in_words
13
+ from recala_cli.endpoints import BILLING_INVOICES, BILLING_PAY_LINK
14
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option
15
+ from recala_cli.views import CARD_PLAN_LABELS, describe_pay_link
16
+
17
+ INVOICE_STATUSES = {"paid": "paid", "open": "waiting for payment", "draft": "not issued yet", "void": "cancelled",
18
+ "uncollectible": "could not be collected"}
19
+ NO_INVOICES_FEATURE = "Invoices are not switched on for this company yet. Your billing page in the portal has them."
20
+
21
+
22
+ @click.group()
23
+ def billing() -> None:
24
+ """Payments for the company's plan (owners)."""
25
+
26
+
27
+ def money(cents: int, currency: str) -> str:
28
+ return f"{cents / 100:,.2f} {currency.upper()}"
29
+
30
+
31
+ def describe_invoices(result: dict[str, Any]) -> str:
32
+ portal = f"{config.web_url()}/portal/billing"
33
+ invoices = result.get("invoices") or []
34
+ if not invoices:
35
+ return f"No invoices yet.\nPayment method and plan changes: {portal}"
36
+ lines = [f"{len(invoices)} most recent invoice{'' if len(invoices) == 1 else 's'}, newest first:"]
37
+ for invoice in invoices:
38
+ day = datetime.fromtimestamp(int(invoice["created"]), tz=timezone.utc).strftime("%d %b %Y").lstrip("0")
39
+ status = INVOICE_STATUSES.get(invoice.get("status") or "", str(invoice.get("status") or "unknown"))
40
+ lines.append(f" {day} {money(invoice['total'], invoice['currency'])} {status}"
41
+ + (f" #{invoice['number']}" if invoice.get("number") else ""))
42
+ if invoice.get("url") and invoice.get("status") != "paid":
43
+ lines.append(f" {invoice['url']}")
44
+ lines.append(f"Payment method and plan changes: {portal}")
45
+ return "\n".join(lines)
46
+
47
+
48
+ @billing.command("invoices")
49
+ @common_options
50
+ @command_runner(human=describe_invoices)
51
+ def invoices(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
52
+ """The company's recent invoices: date, amount and status (owners).
53
+
54
+ Payment method and plan changes happen in the portal; the last line links to it."""
55
+ with refusals_in_words(not_found=NO_INVOICES_FEATURE):
56
+ listed: dict[str, Any] = open_client(ctx, org_flag).call(BILLING_INVOICES)
57
+ return listed
58
+
59
+
60
+ @billing.command("link")
61
+ @common_options
62
+ @command_runner(human=describe_pay_link)
63
+ @click.option("--plan", "plan_key", type=click.Choice(tuple(CARD_PLAN_LABELS)), required=True, help="The monthly plan to sell.")
64
+ @click.option("--email", default=None, help="Billing email for receipts (else the current one; yours for a new customer).")
65
+ @accepted_yes_option
66
+ @dry_run_option
67
+ def pay_link(ctx: click.Context, org_flag: str | None, plan_key: str, email: str | None) -> dict[str, Any]:
68
+ """A 30-day payment link for whoever pays.
69
+
70
+ Email it to the payer; once paid, the plan is granted with no manual step.
71
+ """
72
+ client = open_client(ctx, org_flag)
73
+ confirm(ctx, client, f"Create a payment link for {CARD_PLAN_LABELS[plan_key]} (monthly)? Anyone with it can pay it.")
74
+ body: dict[str, Any] = {"plan": plan_key, **({"email": email} if email else {})}
75
+ link: dict[str, Any] = client.call(BILLING_PAY_LINK, json=body)
76
+ return link
@@ -0,0 +1,164 @@
1
+ """Bing Webmaster Tools: connect through the portal's sign-in, choose the site, read the stored numbers.
2
+
3
+ The numbers come from what Recala has stored, never from a live call to Bing, so exploring them costs nothing.
4
+ Queries and pages are weekly figures and the totals are daily, so they are shown apart and never added together.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ from recala_cli import config
14
+ from recala_cli.client import RecalaClient
15
+ from recala_cli.commands.connect import connect
16
+ from recala_cli.endpoints import BING_PERFORMANCE, BING_SITE, BING_SITES, BING_STATUS, Endpoint
17
+ from recala_cli.errors import CliError, ExitCode
18
+ from recala_cli.polling import BROWSER_POLL_SECONDS, open_in_browser, poll_until
19
+ from recala_cli.prompts import choose
20
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option, dry_run_option
21
+ from recala_cli.search_numbers import ranked_lines, totals_sentence
22
+
23
+ SIGN_IN_WAIT_SECONDS = 600
24
+ NOT_CONNECTED = "Bing Webmaster Tools isn't connected for this company. Connect it: recala connect bing"
25
+ SWITCHED_OFF = "Bing Webmaster Tools isn't switched on for this company yet."
26
+ _NEEDS_SIGN_IN = ("not_connected", "needs_reconnect", None)
27
+ _STATE_SENTENCES = {
28
+ "connecting": "Signed in to Microsoft. Choose the site: recala bing use",
29
+ "needs_reconnect": "{site}: Bing access was lost. Reconnect: recala connect bing",
30
+ "backfilling": "{site}: first import running.",
31
+ "import_retrying": "{site}: the first import failed and will retry. Bing said: {last_error}",
32
+ "waiting_for_data": "{site}: connected, and Bing has no search data for it yet.",
33
+ "live": "{site}: live, data through {data_through}.",
34
+ "stale": "{site}: updates stopped on {stale_since}.",
35
+ }
36
+
37
+
38
+ def describe_status(status: dict[str, Any]) -> str:
39
+ site = (status.get("property") or "").removeprefix("https://").removeprefix("http://").rstrip("/")
40
+ template = _STATE_SENTENCES.get(status.get("state") or "", NOT_CONNECTED)
41
+ return template.format(**{**status, "site": site})
42
+
43
+
44
+ def _call(client: RecalaClient, endpoint: Endpoint, **request: Any) -> Any:
45
+ """A Bing route's refusals, in words: off the rollout (404) or not connected (409)."""
46
+ try:
47
+ return client.call(endpoint, **request)
48
+ except CliError as error:
49
+ if error.exit_code == ExitCode.NOT_FOUND:
50
+ raise CliError(ExitCode.NOT_FOUND, "bing_unavailable", SWITCHED_OFF, 404) from error
51
+ if error.exit_code == ExitCode.CONFLICT:
52
+ raise CliError(ExitCode.CONFLICT, "bing_not_connected", NOT_CONNECTED, 409) from error
53
+ raise
54
+
55
+
56
+ def _read_status(client: RecalaClient) -> dict[str, Any]:
57
+ status: dict[str, Any] | None = optional_read(client, BING_STATUS)
58
+ if status is None:
59
+ raise CliError(ExitCode.NOT_FOUND, "bing_unavailable", SWITCHED_OFF, 404)
60
+ return status
61
+
62
+
63
+ def _choose_site(ctx: click.Context, client: RecalaClient, site: str | None) -> dict[str, Any]:
64
+ listed = _call(client, BING_SITES) or {}
65
+ sites = [entry["site_url"] for entry in listed.get("sites") or []]
66
+ if not sites:
67
+ raise CliError(ExitCode.CONFLICT, "no_sites",
68
+ "This Microsoft account has no verified site in Bing Webmaster Tools. Verify the site there, "
69
+ "or import it from Search Console, then run: recala connect bing. "
70
+ "Wrong Microsoft account? Disconnect on the Settings page, then connect again.")
71
+ chosen = choose(ctx, [(url, url) for url in sites], label="Which site is this company's?", flag="--site", value=site,
72
+ default=listed.get("suggested_site"))
73
+ confirm(ctx, client, f"Use the Bing site {chosen}?")
74
+ _call(client, BING_SITE, json={"site_url": chosen})
75
+ return _read_status(client)
76
+
77
+
78
+ @connect.command("bing")
79
+ @common_options
80
+ @command_runner(human=describe_status)
81
+ @click.option("--site", default=None, help="The site to use, as listed by: recala bing sites.")
82
+ @accepted_yes_option
83
+ @dry_run_option
84
+ def connect_bing(ctx: click.Context, org_flag: str | None, site: str | None) -> dict[str, Any]:
85
+ """Connect Bing Webmaster Tools.
86
+
87
+ Microsoft sign-in in the browser, then choose the site."""
88
+ client = open_client(ctx, org_flag)
89
+ current = _read_status(client)
90
+ if current.get("state") in _NEEDS_SIGN_IN:
91
+ open_in_browser(ctx, f"{config.web_url()}/portal/settings?org={client.org_id}",
92
+ why="In your browser, press Connect on the Bing Webmaster Tools card and approve Recala:")
93
+ current = poll_until(ctx, lambda: _read_status(client), lambda s: s.get("state") not in _NEEDS_SIGN_IN,
94
+ label="Waiting for the Microsoft sign-in in your browser",
95
+ timeout_seconds=SIGN_IN_WAIT_SECONDS, interval_seconds=BROWSER_POLL_SECONDS,
96
+ outside_ticks=False, done=lambda s: "Microsoft account connected")
97
+ if current.get("state") == "connecting" or (site and site != current.get("property")):
98
+ current = _choose_site(ctx, client, site)
99
+ return current
100
+
101
+
102
+ @click.group()
103
+ def bing() -> None:
104
+ """Bing Webmaster Tools: its status, sites and search numbers."""
105
+
106
+
107
+ @bing.command("status")
108
+ @common_options
109
+ @command_runner(human=describe_status)
110
+ def status(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
111
+ """Whether Bing is connected, and how fresh its numbers are."""
112
+ return _read_status(open_client(ctx, org_flag))
113
+
114
+
115
+ def _describe_sites(listed: dict[str, Any]) -> str:
116
+ suggested = listed.get("suggested_site")
117
+ sites = [entry["site_url"] for entry in listed.get("sites") or []]
118
+ ordered = sorted(sites, key=lambda url: url != suggested)
119
+ return "\n".join(f"{url} (suggested)" if url == suggested else url for url in ordered) or "No verified sites."
120
+
121
+
122
+ @bing.command("sites")
123
+ @common_options
124
+ @command_runner(human=_describe_sites)
125
+ def sites(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
126
+ """The sites the connected Microsoft account has verified."""
127
+ listed: dict[str, Any] = _call(open_client(ctx, org_flag), BING_SITES)
128
+ return listed
129
+
130
+
131
+ @bing.command("use")
132
+ @common_options
133
+ @command_runner(human=describe_status)
134
+ @click.argument("site", required=False)
135
+ @accepted_yes_option
136
+ @dry_run_option
137
+ def use(ctx: click.Context, org_flag: str | None, site: str | None) -> dict[str, Any]:
138
+ """Choose which verified site is this company's."""
139
+ return _choose_site(ctx, open_client(ctx, org_flag), site)
140
+
141
+
142
+ def _describe_performance(data: dict[str, Any]) -> str:
143
+ period = data.get("period") or {}
144
+ heading = f"{data.get('property')}, {period.get('start')} to {period.get('end')}"
145
+ totals = data.get("totals")
146
+ if not totals:
147
+ return f"{heading}\nNo Bing numbers stored for this period yet."
148
+ if not totals.get("impressions"): # days Bing reported empty: no search data, never a zero
149
+ return f"{heading}\nBing has no search data for this site in this period yet."
150
+ lines = [heading, f"{totals_sentence(totals)}. Data through {data.get('data_through')}."]
151
+ ranked = ranked_lines("Top queries", data.get("queries") or []) + ranked_lines("Top pages", data.get("pages") or [])
152
+ if ranked:
153
+ lines += ["Weekly figures below, separate from the daily totals above.", *ranked]
154
+ return "\n".join(lines)
155
+
156
+
157
+ @bing.command("data")
158
+ @common_options
159
+ @command_runner(human=_describe_performance)
160
+ @click.option("--days", type=click.IntRange(7, 90), default=28, show_default=True, help="How many days back to read.")
161
+ def data(ctx: click.Context, org_flag: str | None, days: int) -> dict[str, Any]:
162
+ """The stored search numbers: totals, top queries and top pages."""
163
+ answer: dict[str, Any] = _call(open_client(ctx, org_flag), BING_PERFORMANCE, params={"window_days": days})
164
+ return answer
@@ -0,0 +1,217 @@
1
+ """Publication cadence, owner consent and a read-only two-week plan."""
2
+
3
+ from datetime import time
4
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
5
+
6
+ import click
7
+
8
+ from recala_cli.ids import resolve_person_id
9
+ from recala_cli.commands.faithful_translations import faithful_translations
10
+ from recala_cli.commands.reads import describe_slot_state
11
+ from recala_cli.endpoints import (
12
+ AUTOAPPROVE_SET,
13
+ AUTOAPPROVE_SHOW,
14
+ CADENCE_SET,
15
+ CADENCE_SHOW,
16
+ PUBLICATION_PLAN,
17
+ )
18
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option
19
+ from recala_cli.labels import resource_label
20
+ from recala_cli.errors import CliError
21
+ from recala_cli.organizations import organization_label
22
+
23
+ DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
24
+
25
+
26
+ CADENCE_FIELDS = ("days", "local_time", "timezone", "autopilot_enabled", "write_ahead_hours", "daily_generation_cap")
27
+
28
+
29
+ def current_cadence(client):
30
+ """The saved settings a change leaves alone, so a preview describes the result and not just the request."""
31
+ saved = (optional_read(client, CADENCE_SHOW) or {}).get("cadence") or {}
32
+ return {field: saved[field] for field in CADENCE_FIELDS if saved.get(field) is not None}
33
+
34
+
35
+ def show_cadence(result):
36
+ data = result.get("cadence") or {}
37
+ if not data:
38
+ return "No publication cadence configured."
39
+ days = ",".join(DAYS[day] for day in data["days"])
40
+ consent = "on" if data.get("autopilot_enabled") else "off"
41
+ prefix = "Dry run — " if result.get("dry_run") else ""
42
+ return f"{prefix}{days} at {data['local_time']} {data['timezone']} · autopilot={consent}"
43
+
44
+
45
+ def describe_company_cadence(ctx, result):
46
+ company = ctx.meta.get("cadence_company")
47
+ return (f"{company}\n" if company else "") + show_cadence(result)
48
+
49
+
50
+ @click.group()
51
+ def cadence():
52
+ """Show or set publication days and time.
53
+
54
+ Changing them needs an owner session."""
55
+
56
+
57
+ @cadence.command("show")
58
+ @common_options
59
+ @command_runner(contextual_human=describe_company_cadence)
60
+ def show(ctx, org_flag):
61
+ """Show the publication days, time and autopilot setting."""
62
+ client = open_client(ctx, org_flag)
63
+ ctx.meta["cadence_company"] = organization_label(ctx, client)
64
+ return client.call(CADENCE_SHOW)
65
+
66
+
67
+ @cadence.command("set")
68
+ @common_options
69
+ @command_runner(contextual_human=describe_company_cadence)
70
+ @click.option("--days", required=True, help="Comma-separated weekdays: mon,wed,fri.")
71
+ @click.option("--time", "local_time", required=True)
72
+ @click.option("--tz", "timezone", required=True)
73
+ @click.option(
74
+ "--autopilot/--no-autopilot",
75
+ default=None,
76
+ help="Opt into topic assignment and generation; preserves current consent if omitted.",
77
+ )
78
+ @click.option("--ahead-hours", type=click.IntRange(36, 336), default=None)
79
+ @click.option("--daily-cap", type=click.IntRange(1, 10), default=None)
80
+ @click.option(
81
+ "--dry-run", is_flag=True, help="Validate and print proposed settings without saving."
82
+ )
83
+ @accepted_yes_option
84
+ def set_cadence(
85
+ ctx, org_flag, days, local_time, timezone, autopilot, ahead_hours, daily_cap, dry_run
86
+ ):
87
+ """Set publication days and time; --dry-run previews without saving."""
88
+ try:
89
+ parsed_days = sorted({DAYS.index(day.strip().lower()) for day in days.split(",")})
90
+ clock = time.fromisoformat(local_time)
91
+ if clock.tzinfo or clock.second or clock.microsecond:
92
+ raise ValueError()
93
+ ZoneInfo(timezone)
94
+ except (ValueError, ZoneInfoNotFoundError) as error:
95
+ raise click.BadParameter("Use mon..sun, HH:MM and a valid IANA timezone.") from error
96
+ body = {"days": parsed_days, "local_time": clock.strftime("%H:%M"), "timezone": timezone}
97
+ for key, value in (
98
+ ("autopilot_enabled", autopilot),
99
+ ("write_ahead_hours", ahead_hours),
100
+ ("daily_generation_cap", daily_cap),
101
+ ):
102
+ if value is not None:
103
+ body[key] = value
104
+ client = open_client(ctx, org_flag)
105
+ if dry_run:
106
+ return {"dry_run": True, "cadence": {**current_cadence(client), **body}}
107
+ ctx.meta["cadence_company"] = organization_label(ctx, client)
108
+ policy = {}
109
+ if not ctx.obj.get("as_json"):
110
+ try:
111
+ policy = optional_read(client, AUTOAPPROVE_SHOW) or {}
112
+ except CliError:
113
+ pass # A missing display name must not bypass or prevent consent.
114
+
115
+ describe_signer(ctx, client, policy.get("default_author_person_id"))
116
+ signer = ctx.meta.get("author_label") or "the configured signer"
117
+ confirm(ctx, client, f"Save publication cadence ({body['timezone']}, {body['local_time']}) with {signer}?"
118
+ + (" Enable autopilot?" if autopilot else ""))
119
+ return client.call(CADENCE_SET, json=body)
120
+
121
+
122
+ @click.group()
123
+ def autoapprove():
124
+ """Automatic acceptance of passing drafts.
125
+
126
+ Changing it needs an owner session."""
127
+
128
+
129
+ autoapprove.add_command(faithful_translations)
130
+
131
+
132
+ def show_policy(ctx, data):
133
+ consent = "on" if data.get("auto_approve_passing") or data.get("enabled") else "off"
134
+ author = ctx.meta.get("author_label") or data.get("default_author_person_id") or "not configured"
135
+ prefix = "Dry run — " if data.get("dry_run") else ""
136
+ return f"{prefix}Auto-approve: {consent} · author: {author}"
137
+
138
+
139
+ @autoapprove.command("show")
140
+ @common_options
141
+ @command_runner(contextual_human=show_policy)
142
+ def show_approval(ctx, org_flag):
143
+ """Show whether passing drafts are auto-approved."""
144
+ client = open_client(ctx, org_flag)
145
+ policy = client.call(AUTOAPPROVE_SHOW)
146
+ describe_signer(ctx, client, policy.get("default_author_person_id"))
147
+ return policy
148
+
149
+
150
+ def describe_signer(ctx, client, author):
151
+ if author:
152
+ ctx.meta["author_label"] = resource_label(ctx, client, "person", author, result_subject=False)
153
+
154
+
155
+ def change_approval(ctx, org_flag, enabled, author, dry_run):
156
+ client = open_client(ctx, org_flag)
157
+ author = resolve_person_id(client, author) if author else author
158
+ policy = client.call(AUTOAPPROVE_SHOW)
159
+ body = {
160
+ "enabled": enabled,
161
+ "default_author_person_id": author or policy.get("default_author_person_id"),
162
+ }
163
+ describe_signer(ctx, client, body["default_author_person_id"])
164
+ if dry_run:
165
+ return {"dry_run": True, **body}
166
+ signer = ctx.meta.get("author_label") or "the configured signer"
167
+ confirm(ctx, client, f"Turn auto-approve {'on' if enabled else 'off'}, signed by {signer}?")
168
+ return client.call(AUTOAPPROVE_SET, json=body)
169
+
170
+
171
+ @autoapprove.command("on")
172
+ @common_options
173
+ @command_runner(contextual_human=show_policy)
174
+ @click.option(
175
+ "--author", default=None, help="Active signing person's id; otherwise keep the current author."
176
+ )
177
+ @click.option("--dry-run", is_flag=True)
178
+ @accepted_yes_option
179
+ def on(ctx, org_flag, author, dry_run):
180
+ """Turn on automatic approval for this organization."""
181
+ return change_approval(ctx, org_flag, True, author, dry_run)
182
+
183
+
184
+ @autoapprove.command("off")
185
+ @common_options
186
+ @command_runner(contextual_human=show_policy)
187
+ @click.option("--dry-run", is_flag=True)
188
+ @accepted_yes_option
189
+ def off(ctx, org_flag, dry_run):
190
+ """Turn off automatic approval for this organization."""
191
+ return change_approval(ctx, org_flag, False, None, dry_run)
192
+
193
+
194
+ def describe_plan(result):
195
+ from recala_cli.views import moment_in
196
+ cadence = result.get("cadence") or {}
197
+ zone = cadence.get("timezone") or result.get("timezone") or "UTC"
198
+ lines = ["Dry run — no changes"]
199
+ for slot in result.get("slots") or []:
200
+ lines.append(
201
+ f"{moment_in(slot.get('scheduled_at'), zone)} "
202
+ f"{slot.get('topic') or slot.get('title') or '(no topic)'} · "
203
+ f"{describe_slot_state(slot, cadence)}"
204
+ )
205
+ lines.extend(f"{topic['topic']} -> skipped -> {topic['reason']}" for topic in result.get("skipped_topics", []))
206
+ return "\n".join(lines)
207
+
208
+
209
+ @click.command()
210
+ @common_options
211
+ @command_runner(human=describe_plan)
212
+ @click.option("--weeks", type=click.IntRange(1, 2), default=2, show_default=True)
213
+ def plan(ctx, org_flag, weeks):
214
+ """Preview each slot, topic and state.
215
+
216
+ Never generates, approves or publishes."""
217
+ return open_client(ctx, org_flag).call(PUBLICATION_PLAN, params={"weeks": weeks})