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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- 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 []}
|