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
recala_cli/ui.py ADDED
@@ -0,0 +1,260 @@
1
+ """How the CLI looks to a person: the wordmark, the brand palette, spinners, progress and checklists.
2
+
3
+ Everything here writes to stderr; stdout is only ever the answer. Colour follows
4
+ the terminal: none under NO_COLOR, TERM=dumb or off a TTY, and never in --json
5
+ (those commands do not call this module). Animation also needs a TTY of known width,
6
+ and stops under CI or RECALA_NO_ANIMATION, so logs and agents see one line per event.
7
+
8
+ The palette is recala.co's: the rust asterisk (#C05621) is the only accent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import threading
16
+ import time
17
+ import unicodedata
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from typing import IO, Any
21
+
22
+ _RESET = "\x1b[0m"
23
+ _RUST_TRUECOLOR = "\x1b[38;2;192;86;33m" # #C05621, the wordmark's asterisk
24
+ _RUST_256 = "\x1b[38;5;166m"
25
+ _CODES = {"ok": "\x1b[32m", "warn": "\x1b[33m", "err": "\x1b[31m", "dim": "\x1b[2m", "bold": "\x1b[1m"}
26
+ _MARKS = {
27
+ True: {"done": "✓", "now": "●", "next": "○", "fail": "✗", "skip": "–"},
28
+ False: {"done": "[x]", "now": "[>]", "next": "[ ]", "fail": "[!]", "skip": "[-]"},
29
+ }
30
+ #: The asterisk pulses while the CLI waits; ASCII terminals get a plain spinner.
31
+ _FRAMES = {True: ("·", "∗", "*", "✳", "✻", "✳", "*", "∗"), False: ("-", "\\", "|", "/")}
32
+ _FRAME_SECONDS = 0.09
33
+ #: Narrower than this, a frame has no room for its label: one line per event instead.
34
+ _MIN_COLUMNS = 8
35
+
36
+ #: figlet "small" lowercase, unsmushed so every letter stays legible; the accent is added by ``wordmark``.
37
+ _WORDMARK = (
38
+ r" _ ",
39
+ r" _ _ ___ __ __ _ | | __ _ ",
40
+ r"| '_|/ -_)/ _|/ _` || |/ _` |",
41
+ r"|_| \___|\__|\__,_||_|\__,_|",
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Style:
47
+ color: bool
48
+ animate: bool
49
+ unicode: bool = True
50
+ truecolor: bool = False
51
+
52
+ @classmethod
53
+ def for_stream(cls, stream: Any = None) -> Style:
54
+ stream = sys.stderr if stream is None else stream
55
+ tty = bool(getattr(stream, "isatty", lambda: False)())
56
+ dumb = os.environ.get("TERM", "") == "dumb"
57
+ color = tty and not dumb and "NO_COLOR" not in os.environ
58
+ animate = tty and not dumb and not os.environ.get("CI") and not os.environ.get("RECALA_NO_ANIMATION")
59
+ encoding = (getattr(stream, "encoding", None) or os.environ.get("LC_ALL") or os.environ.get("LANG") or "utf-8")
60
+ return cls(color=color, animate=animate, unicode="utf" in encoding.lower(),
61
+ truecolor=os.environ.get("COLORTERM", "") in ("truecolor", "24bit"))
62
+
63
+ @classmethod
64
+ def plain(cls) -> Style:
65
+ return cls(color=False, animate=False, unicode=False)
66
+
67
+ def _paint(self, code: str, text: str) -> str:
68
+ return f"{code}{text}{_RESET}" if self.color else text
69
+
70
+ def accent(self, text: str) -> str:
71
+ return self._paint(_RUST_TRUECOLOR if self.truecolor else _RUST_256, text)
72
+
73
+ def ok(self, text: str) -> str:
74
+ return self._paint(_CODES["ok"], text)
75
+
76
+ def warn(self, text: str) -> str:
77
+ return self._paint(_CODES["warn"], text)
78
+
79
+ def err(self, text: str) -> str:
80
+ return self._paint(_CODES["err"], text)
81
+
82
+ def dim(self, text: str) -> str:
83
+ return self._paint(_CODES["dim"], text)
84
+
85
+ def bold(self, text: str) -> str:
86
+ return self._paint(_CODES["bold"], text)
87
+
88
+ def mark(self, state: str) -> str:
89
+ symbol = _MARKS[self.unicode][state]
90
+ painter = {"done": self.ok, "now": self.accent, "fail": self.err}.get(state, self.dim)
91
+ return painter(symbol)
92
+
93
+ def frames(self) -> tuple[str, ...]:
94
+ return _FRAMES[self.unicode]
95
+
96
+ def ellipsis(self) -> str:
97
+ return "…" if self.unicode else "..."
98
+
99
+
100
+ def wordmark(style: Style) -> str:
101
+ """The recala wordmark with its rust asterisk, as recala.co draws it."""
102
+ lines = list(_WORDMARK)
103
+ lines[1] = f"{lines[1]} {style.accent('*')}"
104
+ return "\n".join(style.bold(line) if index != 1 else line for index, line in enumerate(lines))
105
+
106
+
107
+ def banner(subtitle: str, *, style: Style, stream: IO[str] | None = None) -> None:
108
+ """The wordmark, drawn in with a short sparkle on a live terminal (about a third of a second)."""
109
+ out = sys.stderr if stream is None else stream
110
+ out.write("\n" + wordmark(style) + "\n")
111
+ if style.animate:
112
+ for frame in style.frames():
113
+ out.write(f"\r {style.accent(frame)} {style.dim(subtitle)}")
114
+ out.flush()
115
+ time.sleep(0.04)
116
+ out.write("\r\x1b[K")
117
+ out.write(f" {style.accent('*')} {style.dim(subtitle)}\n\n")
118
+ out.flush()
119
+
120
+
121
+ def progress_bar(done: int, total: int, *, width: int = 24, style: Style) -> str:
122
+ shown = max(0, min(done, total))
123
+ filled = round(width * shown / total) if total else 0
124
+ full, empty = ("█", "░") if style.unicode else ("#", "-")
125
+ return f"[{style.accent(full * filled)}{style.dim(empty * (width - filled))}] {shown}/{total}"
126
+
127
+
128
+ def checklist(rows: list[tuple[str, str, str]], *, style: Style) -> str:
129
+ """One line per step: its mark, number, title and a short detail (``state`` is done, now, next, fail or skip)."""
130
+ title_width = max((len(title) for title, _, _ in rows), default=0) + 4
131
+ number_width = len(str(len(rows)))
132
+ arrow = "← now" if style.unicode else "<- now"
133
+ lines = []
134
+ for index, (title, state, detail) in enumerate(rows, start=1):
135
+ shown = detail or (arrow if state == "now" else "")
136
+ painted = style.accent(shown) if state == "now" and not detail else style.dim(shown)
137
+ line = f" {style.mark(state)} {index:>{number_width}} {title:<{title_width}}{painted if shown else ''}"
138
+ lines.append(line.rstrip())
139
+ return "\n".join(lines)
140
+
141
+
142
+ def cell_width(text: str) -> int:
143
+ """The columns ``text`` takes on a terminal: wide characters take two, combining marks none."""
144
+ return sum(_char_width(char) for char in text)
145
+
146
+
147
+ def _char_width(char: str) -> int:
148
+ if unicodedata.combining(char):
149
+ return 0
150
+ return 2 if unicodedata.east_asian_width(char) in "WF" else 1
151
+
152
+
153
+ def fit(text: str, columns: int, *, ellipsis: str) -> str:
154
+ """``text`` cut to at most ``columns`` cells, ending in ``ellipsis`` when it had to be cut."""
155
+ if cell_width(text) <= columns:
156
+ return text
157
+ room, kept = columns - cell_width(ellipsis), ""
158
+ for char in text:
159
+ if cell_width(kept + char) > room:
160
+ break
161
+ kept += char
162
+ return kept.rstrip() + ellipsis if room >= 0 else ellipsis[:columns]
163
+
164
+
165
+ def status_line(frame: str, label: str, elapsed: int, *, columns: int, style: Style) -> str:
166
+ """One spinner frame that never wraps: a wrapped frame leaves a new line behind on every redraw.
167
+
168
+ It stops one column short of the edge (writing the last column makes some terminals wrap),
169
+ drops the clock before it shortens the label, and shortens the label with an ellipsis.
170
+ """
171
+ room = columns - 1
172
+ lead = f" {frame} "
173
+ clock = f" {elapsed}s" if elapsed >= 2 else ""
174
+ if cell_width(lead + label + clock) > room:
175
+ clock = ""
176
+ shown = fit(label, max(room - cell_width(lead), 0), ellipsis=style.ellipsis())
177
+ return f" {style.accent(frame)} {shown}{style.dim(clock) if clock else ''}"
178
+
179
+
180
+ def terminal_columns(stream: Any) -> int | None:
181
+ """The width of the terminal behind ``stream``; None off a terminal or when unknown."""
182
+ try:
183
+ return os.get_terminal_size(stream.fileno()).columns or None
184
+ except (AttributeError, OSError, ValueError):
185
+ return None
186
+
187
+
188
+ class Spinner:
189
+ """A pulsing asterisk with a label while something slow runs; one line per event elsewhere.
190
+
191
+ ``columns`` reads the terminal's width, every frame, so a resized window still gets one line;
192
+ None means unknown, and then nothing animates.
193
+ """
194
+
195
+ def __init__(self, label: str, *, stream: IO[str] | None = None, style: Style | None = None,
196
+ columns: Callable[[], int | None] | None = None, tty_only: bool = False) -> None:
197
+ self.label = label
198
+ self._tty_only = tty_only # on a pipe or in a log: no progress lines at all
199
+ self._stream = sys.stderr if stream is None else stream
200
+ self._style = style or Style.for_stream(self._stream)
201
+ self._columns = columns or (lambda: terminal_columns(self._stream))
202
+ self._animate = False
203
+ self._finished = False
204
+ self._stop = threading.Event()
205
+ self._thread: threading.Thread | None = None
206
+ self._started = time.monotonic()
207
+
208
+ def __enter__(self) -> Spinner:
209
+ self._animate = self._style.animate and (self._columns() or 0) >= _MIN_COLUMNS
210
+ if self._animate:
211
+ self._thread = threading.Thread(target=self._spin, daemon=True)
212
+ self._thread.start()
213
+ elif not self._tty_only:
214
+ self._write(f"{self.label} ...\n")
215
+ return self
216
+
217
+ def update(self, label: str) -> None:
218
+ self.label = label
219
+ if not self._animate and not self._tty_only:
220
+ self._write(f"{label}\n")
221
+
222
+ def done(self, message: str) -> None:
223
+ self._finish(f" {self._style.mark('done')} {message}" if self._animate else message)
224
+
225
+ def fail(self, message: str) -> None:
226
+ self._finish(f" {self._style.mark('fail')} {message}" if self._animate else message)
227
+
228
+ def __exit__(self, kind: Any, error: Any, trace: Any) -> None:
229
+ if not self._finished:
230
+ if kind is None:
231
+ self.done(self.label)
232
+ else:
233
+ self.fail(f"{self.label}: stopped")
234
+
235
+ def _finish(self, line: str) -> None:
236
+ self._finished = True
237
+ if self._tty_only and not self._animate:
238
+ return
239
+ self._stop.set()
240
+ if self._thread is not None:
241
+ self._thread.join(timeout=1)
242
+ self._write("\r\x1b[K")
243
+ self._write(f"{line}\n")
244
+
245
+ def _spin(self) -> None:
246
+ frames = self._style.frames()
247
+ index = 0
248
+ while not self._stop.is_set():
249
+ columns = self._columns()
250
+ if columns is not None and columns >= _MIN_COLUMNS: # resized too narrow: skip
251
+ elapsed = int(time.monotonic() - self._started)
252
+ frame = frames[index % len(frames)]
253
+ line = status_line(frame, self.label, elapsed, columns=columns, style=self._style)
254
+ self._write(f"\r\x1b[K{line}")
255
+ index += 1
256
+ self._stop.wait(_FRAME_SECONDS)
257
+
258
+ def _write(self, text: str) -> None:
259
+ self._stream.write(text)
260
+ self._stream.flush()
recala_cli/updates.py ADDED
@@ -0,0 +1,43 @@
1
+ """Best-effort daily notices from the existing public CLI release pointer."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from datetime import UTC, datetime
7
+
8
+ import click
9
+ import httpx
10
+
11
+ from recala_cli import __version__, config
12
+
13
+ LATEST_URL = "https://recala.co/releases/cli/LATEST"
14
+ UPGRADE = "recala update"
15
+
16
+
17
+ def latest_version():
18
+ response = httpx.get(LATEST_URL, timeout=2, follow_redirects=False)
19
+ response.raise_for_status()
20
+ version = response.text.strip()
21
+ return version if re.fullmatch(r"\d+\.\d+\.\d+", version) else None
22
+
23
+
24
+ def notify_update(*, as_json):
25
+ if as_json or os.environ.get("RECALA_NO_UPDATE_CHECK") == "1":
26
+ return
27
+ path = config.config_dir() / "update-check.json"
28
+ today = datetime.now(UTC).date().isoformat()
29
+ try:
30
+ if path.exists() and json.loads(path.read_text()).get("checked_on") == today:
31
+ return
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ path.write_text(json.dumps({"checked_on": today}))
34
+ latest = latest_version()
35
+ if latest and tuple(map(int, latest.split("."))) > tuple(
36
+ map(int, __version__.split("."))
37
+ ):
38
+ click.echo(
39
+ f"Recala {latest} is available (installed {__version__}). Update: {UPGRADE}",
40
+ err=True,
41
+ )
42
+ except (OSError, ValueError, httpx.HTTPError):
43
+ pass # Release availability never blocks company work.
recala_cli/views.py ADDED
@@ -0,0 +1,372 @@
1
+ """Human views: what a person reads in the terminal. Pure functions of the API's answers.
2
+
3
+ --json never comes through here: agents get the answer itself. Each view says what
4
+ the state is and, when something is missing, the one command that fixes it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import defaultdict
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
14
+
15
+ import click
16
+
17
+ from recala_cli import config
18
+ from recala_cli.ui import Style
19
+
20
+ #: The CLI-facing way to pay: an owner makes a 30-day link for whoever pays (not the web billing page).
21
+ PAY_LINK_STEP = "an owner makes a pay link with recala billing link --plan go (or --plan self_serve)"
22
+
23
+
24
+ def pay_link_step() -> str:
25
+ """PAY_LINK_STEP for the company this command ran on: `recala billing link` acts on the saved active company,
26
+ so when --org / RECALA_ORG chose another one the hint must say which."""
27
+ context = click.get_current_context(silent=True)
28
+ resolved = context.meta.get("resolved_org") if context else None
29
+ if resolved and resolved != config.saved_active_org():
30
+ return PAY_LINK_STEP.replace("recala billing link", f"recala billing link --org {resolved}")
31
+ return PAY_LINK_STEP
32
+
33
+ #: The engines as the panel names them (src/question_panel/types.py ENGINE_NAMES).
34
+ _ENGINE_NAMES = {"google_organic": "Google", "google_aio": "Google AI Overview", "chatgpt": "ChatGPT", "gemini": "Gemini"}
35
+ _PLAN_NAMES = {"go": "Recala Go", "self_serve": "Self-serve", "managed": "Managed", "free": "No plan yet"}
36
+ _GSC_STATES = {
37
+ "not_connected": "not connected",
38
+ "connecting": "signed in; choose the property",
39
+ "pending_review": "waiting for Recala to confirm the property",
40
+ "needs_reconnect": "needs you to sign in to Google again",
41
+ "backfilling": "connected; importing your history",
42
+ "waiting_for_data": "connected; waiting for Google's first data",
43
+ "live": "live",
44
+ "stale": "stale",
45
+ }
46
+
47
+
48
+ def _day(value: str | None, zone: str | None = None) -> str:
49
+ moment = _local(value, zone) if zone else _parse(value)
50
+ return f"{moment.day} {moment:%b %Y}" if moment else ""
51
+
52
+
53
+ def _parse(value: str | None) -> datetime | None:
54
+ if not value:
55
+ return None
56
+ try:
57
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ def _local(value: str | None, zone: str | None) -> datetime | None:
63
+ moment = _parse(value)
64
+ if moment is None:
65
+ return None
66
+ if moment.tzinfo is None:
67
+ moment = moment.replace(tzinfo=UTC)
68
+ try:
69
+ return moment.astimezone(ZoneInfo(zone or "UTC"))
70
+ except (ValueError, ZoneInfoNotFoundError):
71
+ return moment.astimezone(UTC)
72
+
73
+
74
+ def local_time(value: str | None, zone: str | None) -> str:
75
+ """A short company-local instant; the view labels its timezone once."""
76
+ moment = _local(value, zone)
77
+ return f"{moment:%a} {moment.day} {moment:%b, %H:%M}" if moment else "date not recorded"
78
+
79
+
80
+ _INSTANT = re.compile(r"\d{4}-\d\d-\d\d[T ]\d\d:\d\d(?::\d\d(?:\.\d+)?)?(?:Z|[+-]\d\d:?\d\d)?")
81
+
82
+
83
+ def localise_instants(value: Any, zone: str | None) -> tuple[Any, bool]:
84
+ """A copy of a result with every ISO instant written as the company reads it, and whether any was found.
85
+
86
+ Only for human views: --json keeps the API's own strings."""
87
+ if isinstance(value, str) and _INSTANT.fullmatch(value) and _parse(value) is not None:
88
+ return local_time(value, zone), True
89
+ if isinstance(value, dict):
90
+ converted = {key: localise_instants(item, zone) for key, item in value.items()}
91
+ return {key: item for key, (item, _) in converted.items()}, any(found for _, found in converted.values())
92
+ if isinstance(value, list):
93
+ converted_items = [localise_instants(item, zone) for item in value]
94
+ return [item for item, _ in converted_items], any(found for _, found in converted_items)
95
+ return value, False
96
+
97
+
98
+ def timezone_label(zone: str | None) -> str:
99
+ if zone:
100
+ try:
101
+ ZoneInfo(zone)
102
+ return f"({zone})"
103
+ except (ValueError, ZoneInfoNotFoundError):
104
+ pass
105
+ return "(timezone not set; times in UTC)"
106
+
107
+
108
+ def company_timezone(ctx, data: dict[str, Any]) -> str | None:
109
+ """The company's zone, read the way `recala plan` reads it: payload first, then the saved publication cadence,
110
+ then the company record. One lookup per command; a view that has no zone says so instead of guessing."""
111
+ zone = ((data.get("org") or {}).get("timezone")
112
+ or (data.get("cadence") or {}).get("timezone"))
113
+ if zone:
114
+ return zone
115
+ # The calendar API substitutes UTC when the company's timezone is unset.
116
+ if data.get("timezone") and data["timezone"] != "UTC":
117
+ return data["timezone"]
118
+ if "company_zone" in ctx.meta:
119
+ return ctx.meta["company_zone"] # type: ignore[no-any-return]
120
+ ctx.meta["company_zone"] = _read_company_zone(ctx.meta.get("org_client"))
121
+ return ctx.meta["company_zone"] # type: ignore[no-any-return]
122
+
123
+
124
+ def _read_company_zone(client) -> str | None:
125
+ from recala_cli.endpoints import CADENCE_SHOW, ORG_SHOW
126
+ from recala_cli.errors import CliError
127
+
128
+ if client is None:
129
+ return None
130
+ for read in (lambda: ((client.call(CADENCE_SHOW) or {}).get("cadence") or {}).get("timezone"),
131
+ lambda: (client.call(ORG_SHOW, path={"org_id": client.org_id}).get("org") or {}).get("timezone")):
132
+ try:
133
+ zone = read()
134
+ except CliError:
135
+ continue
136
+ if zone:
137
+ return zone # type: ignore[no-any-return]
138
+ return None
139
+
140
+
141
+ def engine_name(engine: str) -> str:
142
+ return _ENGINE_NAMES.get(engine, engine.replace("_", " "))
143
+
144
+
145
+ def moment_in(value: str | None, zone: str | None) -> str:
146
+ """'Tue 6 Oct 2026, 09:00 (Europe/Madrid)': a stored instant as the company reads it."""
147
+ moment = _local(value, zone)
148
+ return f"{moment:%a} {moment.day} {moment:%b %Y, %H:%M} ({zone or 'UTC'})" if moment else str(value or "")
149
+
150
+
151
+ def _count(value: Any) -> str:
152
+ number = float(value or 0)
153
+ return f"{number:g}"
154
+
155
+
156
+ # ── credits ─────────────────────────────────────────────────────────────────────
157
+
158
+
159
+ def credit_lines(summary: dict[str, Any], entitlements: dict[str, Any] | None, managed: dict[str, Any] | None,
160
+ *, show_question_cap: bool = True, zone: str | None = None) -> list[str]:
161
+ """Plan, what is left, caps and the reset date. An internal account is unlimited, never "0 available"."""
162
+ if summary.get("dogfood") and summary.get("period"):
163
+ return _dogfood_credit_lines(summary, zone) + _cap_lines(entitlements, show_question_cap=show_question_cap)
164
+ plan = summary.get("plan")
165
+ if plan == "internal":
166
+ return ["Unlimited: no plan caps apply."]
167
+ if not summary:
168
+ return ["Credits: unavailable right now; try again in a minute."]
169
+ if plan == "managed" or (managed or {}).get("managed"):
170
+ usage = managed or {}
171
+ period = summary.get("period") or {}
172
+ cap, used = int(usage.get("cap") or period.get("allowance") or 0), int(usage.get("used") or 0)
173
+ ends = _day(usage.get("period_end") or period.get("ends_at"), zone)
174
+ return [f"Managed plan: {used} of {cap} articles used this month" + (f" (resets {ends})" if ends else "")]
175
+ period = summary.get("period")
176
+ lines = []
177
+ if period:
178
+ label = summary.get("plan_name") or _PLAN_NAMES.get(str(summary.get("plan_key") or plan), str(plan))
179
+ renews = "renews" if period.get("renews") else "ends"
180
+ lines.append(f"{label}: {_count(period.get('left'))} of {_count(period.get('allowance'))} articles left this "
181
+ f"period ({renews} {_day(period.get('ends_at'), zone)})")
182
+ else:
183
+ lines.append(f"No plan yet: {_count(summary.get('free_left'))} free article(s) left. "
184
+ f"Human payment: {pay_link_step()}; then recala status")
185
+ if float(summary.get("extra_left") or 0) > 0:
186
+ lines.append(f"Extra articles: {_count(summary.get('extra_left'))}")
187
+ lines.extend(_cap_lines(entitlements, show_question_cap=show_question_cap))
188
+ return lines
189
+
190
+
191
+ def _dogfood_credit_lines(summary: dict[str, Any], zone: str | None = None) -> list[str]:
192
+ """The allowance and the extras past it, in a customer's words: at no charge, or refused when extras are not included.
193
+
194
+ The accounting detail (what it would cost, which limit) stays in --json."""
195
+ period, comp = summary["period"], summary["dogfood"]
196
+ label = summary.get("plan_name") or _PLAN_NAMES.get(str(summary.get("plan_key") or summary.get("plan")), "No plan")
197
+ allowance = period["allowance"]
198
+ used = period.get("used", allowance - period["left"])
199
+ lines = [f"{label}: {used} of {allowance} articles used" if used <= allowance
200
+ else f"{label}: {used} articles this period, {allowance} included in the plan"]
201
+ if period.get("ends_at"):
202
+ renews = "renews" if period.get("renews") else "ends"
203
+ lines[0] += f" ({renews} {_day(period['ends_at'], zone)})"
204
+ if comp.get("comp_overflow") is False: # reserve_dogfood_article answers cap_reached past the allowance
205
+ return lines + [f"Extra articles are not included: articles past the {allowance}-article allowance are refused"]
206
+ extras = comp.get("extras_priced", comp["extras_used"]) + (comp.get("extras_over_limit") or 0)
207
+ if extras:
208
+ priced_only = not comp.get("extras_over_limit") # past the extras limit the price no longer adds up
209
+ worth = f" (worth €{comp['would_charge_cents'] / 100:g})" if comp.get("would_charge_cents") and priced_only else ""
210
+ lines.append(f"Extra articles this period: {extras}, at no charge{worth}")
211
+ lines.append(f"Your next extra article is at no charge (normally €{comp['extra_price_cents'] / 100:g})")
212
+ return lines
213
+
214
+
215
+ def _cap_lines(entitlements: dict[str, Any] | None, *, show_question_cap: bool = True) -> list[str]:
216
+ if not entitlements or not entitlements.get("plan"):
217
+ return []
218
+ questions, cadences = entitlements.get("questions_tracked") or {}, entitlements.get("cadences") or {}
219
+ lines = [f"Questions: up to {questions.get('total', 0)} tracked"] if show_question_cap else []
220
+ if cadences.get("llm_runs_per_week"):
221
+ lines.append(f"AI answer checks: {cadences['llm_runs_per_week']} a week "
222
+ f"({', '.join(engine_name(engine) for engine in cadences.get('llm_engines') or [])})")
223
+ if entitlements.get("cms_connections"):
224
+ lines.append(f"CMS connections: {entitlements['cms_connections']}")
225
+ return lines
226
+
227
+
228
+ CARD_PLAN_LABELS = {"go": "Recala Go", "self_serve": "Self-serve"}
229
+
230
+
231
+ def describe_pay_link(result: dict[str, Any]) -> str:
232
+ """`recala billing link`: the URL on its own line, so it can be copied into an email."""
233
+ plan = CARD_PLAN_LABELS.get(result.get("plan", ""), result.get("plan", ""))
234
+ return (f"Payment link for {plan} (monthly), valid until {_day(result.get('expires_at'))}:\n{result['url']}\n"
235
+ "Once it is paid, Recala grants the plan to this company. Anyone with the link can pay it.")
236
+
237
+
238
+ def describe_credits(result: dict[str, Any]) -> str:
239
+ return "\n".join(credit_lines(result.get("summary") or {}, result.get("entitlements"), result.get("managed_usage")))
240
+
241
+
242
+ # ── calendar ────────────────────────────────────────────────────────────────────
243
+
244
+
245
+ def calendar_view(calendar: dict[str, Any], *, style: Style | None = None,
246
+ cadence: dict[str, Any] | None = None) -> str:
247
+ """Scheduled publications by day: time, title, author and state; then what is ready to book."""
248
+ if calendar.get("enabled") is False and not calendar.get("slots"):
249
+ return "Publication calendar: scheduling is not enabled"
250
+ style = style or Style.plain()
251
+ from recala_cli.commands.reads import _calendar_candidates, _needs_attention, describe_slot_state
252
+
253
+ zone = calendar.get("timezone")
254
+ cadence = cadence or {}
255
+ by_article = {c.get("article_id"): c for c in _calendar_candidates(calendar)}
256
+ days: dict[str, list[str]] = defaultdict(list)
257
+ for appointment in sorted(calendar.get("appointments") or [], key=lambda a: str(a.get("scheduled_at"))):
258
+ moment = _local(appointment.get("scheduled_at"), zone)
259
+ article = by_article.get(appointment.get("article_id")) or {}
260
+ author = article.get("author_name") or "no author"
261
+ when = moment.strftime("%H:%M") if moment else "--:--"
262
+ days[f"{moment:%Y-%m-%d}" if moment else "unknown day"].append(
263
+ f" {when} {article.get('title') or '(untitled article)'} {style.dim('· ' + author)} "
264
+ f"[{describe_slot_state(appointment, cadence)}]")
265
+ booked_articles = {a.get("article_id") for a in calendar.get("appointments") or []}
266
+ for slot in calendar.get("slots") or []:
267
+ if slot.get("article_id") and slot["article_id"] in booked_articles and not _needs_attention(slot):
268
+ continue
269
+ moment = _local(slot.get("scheduled_at"), zone)
270
+ when = moment.strftime("%H:%M") if moment else "--:--"
271
+ days[f"{moment:%Y-%m-%d}" if moment else "unknown day"].append(
272
+ f" {when} {slot.get('topic') or slot.get('title') or '(no topic)'} [{describe_slot_state(slot, cadence)}]")
273
+ lines = [f"Publication calendar {timezone_label(zone)}"]
274
+ for day, rows in sorted(days.items()):
275
+ moment = _parse(day)
276
+ lines.append(style.bold(f"{moment:%a} {moment.day} {moment:%b}" if moment else day))
277
+ lines.extend(sorted(rows))
278
+ if not days:
279
+ lines.append(" Nothing scheduled in this window.")
280
+ booked = {a.get("article_id") for a in calendar.get("appointments") or []}
281
+ ready = [c for c in calendar.get("candidates") or [] if c.get("can_schedule") and c.get("article_id") not in booked]
282
+ if ready:
283
+ lines.append("Ready to schedule:")
284
+ lines.extend(f" {c.get('title')} -> recala article schedule {c.get('article_id')} --at <time>" for c in ready)
285
+ return "\n".join(lines)
286
+
287
+
288
+ # ── connections ─────────────────────────────────────────────────────────────────
289
+
290
+
291
+ def connection_lines(status: dict[str, Any], *, zone: str | None = None) -> list[str]:
292
+ """The CMS (its endpoint and the public site articles appear on) and Search Console, each with its next step."""
293
+ cms, gsc, org = status.get("cms"), status.get("search_console"), status.get("org") or {}
294
+ public_site = org.get("blog_base_url") or org.get("website_url") or ""
295
+ lines = ["Publishing (CMS)"]
296
+ if cms and cms.get("connected"):
297
+ lines.append(f" {cms.get('provider')} connected")
298
+ sanity = status.get("sanity") or {}
299
+ if sanity.get("project_id"):
300
+ lines.append(f" CMS endpoint: Sanity project {sanity['project_id']}, dataset {sanity.get('dataset')}")
301
+ elif cms.get("site_url"):
302
+ lines.append(f" CMS endpoint: {cms['site_url']}")
303
+ if public_site:
304
+ lines.append(f" Public site: {public_site}")
305
+ lines.append(f" Auto-publish: {'on' if cms.get('auto_publish') else 'off (you approve each article)'}")
306
+ else:
307
+ lines.append(" not connected -> recala cms connect --provider <sanity|wordpress>")
308
+ lines.append("Search Console")
309
+ if gsc is None:
310
+ lines.append(" switched off on this Recala server")
311
+ elif gsc.get("state") in (None, "not_connected"):
312
+ lines.append(" not connected -> recala connect gsc")
313
+ else:
314
+ lines.append(f" {_GSC_STATES.get(gsc['state'], gsc['state'])}"
315
+ + (f" · {gsc['property']}" if gsc.get("property") else ""))
316
+ if gsc.get("final_through"):
317
+ through = gsc["final_through"]
318
+ lines.append(f" Data through: {local_time(through, zone) if 'T' in through else through}")
319
+ if gsc.get("state") == "connecting":
320
+ lines.append(" -> recala connect gsc --property <property>")
321
+ if gsc.get("state") == "needs_reconnect":
322
+ lines.append(" -> recala connect gsc")
323
+ sitemap = status.get("sitemap")
324
+ if sitemap:
325
+ lines.extend(sitemap_lines(sitemap, zone))
326
+ return lines
327
+
328
+
329
+ def sitemap_lines(sitemap: dict[str, Any], zone: str | None = None) -> list[str]:
330
+ from recala_cli.commands.index import describe_sitemaps
331
+
332
+ return ["Sitemaps in Search Console", *describe_sitemaps(sitemap, zone).splitlines()]
333
+
334
+
335
+ # ── question panel ──────────────────────────────────────────────────────────────
336
+
337
+
338
+ def question_counts(panel: dict[str, Any]) -> tuple[int, int]:
339
+ """C5 counts when available, otherwise the legacy size and question rows."""
340
+ count = sum(len(area.get("questions") or []) for area in panel.get("areas") or [])
341
+ showing, total = panel.get("showing"), panel.get("total")
342
+ return (showing if showing is not None else count,
343
+ total if total is not None else panel.get("size", count))
344
+
345
+
346
+ def panel_lines(panel: dict[str, Any], entitlements: dict[str, Any] | None = None) -> list[str]:
347
+ if not panel or not panel.get("enabled"):
348
+ return ["Questions: the question panel is switched off on this Recala server"]
349
+ details = panel.get("panel") or {}
350
+ size = question_counts(details)[1]
351
+ baseline = panel.get("baseline")
352
+ run = panel.get("latest_run") or {}
353
+ if not size and not run:
354
+ return ["Questions: no baseline yet; Recala starts this for you"]
355
+ cap = ((entitlements or {}).get("questions_tracked") or {}).get("total")
356
+ allowance = f" of {cap} allowed" if (entitlements or {}).get("plan") and cap is not None else ""
357
+ lines = [f"Questions: {size} tracked{allowance}" + (f", baseline {str(run.get('status')).replace('_', ' ')}" if run else "")]
358
+ for engine, counts in (panel.get("summary") or {}).items():
359
+ share = counts.get("owned_share")
360
+ lines.append(f" {engine_name(engine)}: you own {round(share * 100) if share is not None else 0}% "
361
+ "of the answers")
362
+ if baseline is None and run.get("status") not in ("running", "queued"):
363
+ lines.append(" Recala starts this for you.")
364
+ return lines
365
+
366
+
367
+ def planned_calendar(calendar, plan):
368
+ """Overlay the two-week cadence forecast on scheduled publications."""
369
+ if calendar.get("enabled") is False and not plan.get("slots"):
370
+ return {**calendar, "slots": []}
371
+ return {**calendar, "timezone": (plan.get("cadence") or {}).get("timezone") or calendar.get("timezone"),
372
+ "slots": plan.get("slots") or []}