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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""`recala company languages`: the languages a company publishes in besides its main one.
|
|
2
|
+
|
|
3
|
+
The extra languages are the setting that starts translations (organizations.extra_languages):
|
|
4
|
+
a new article is translated into each of them. The route is the portal's Settings > Languages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from recala_cli.commands.refusals import refusals_in_words
|
|
14
|
+
from recala_cli.endpoints import LANGUAGES_SET, LANGUAGES_SHOW
|
|
15
|
+
from recala_cli.errors import CliError, ExitCode
|
|
16
|
+
from recala_cli.runtime import command_runner, common_options, confirm, ignored_yes_option, open_client
|
|
17
|
+
|
|
18
|
+
NOT_ENABLED = "Languages are not switched on for this company yet, so there is nothing to change."
|
|
19
|
+
CHANGED = "The languages changed while you were editing them. Run `recala company languages show` and try again."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _name(code: str, languages: dict[str, Any]) -> str:
|
|
23
|
+
names = {item["code"]: item["name"] for item in languages.get("supported") or []}
|
|
24
|
+
return f"{names.get(code, code)} ({code})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def describe_languages(languages: dict[str, Any]) -> str:
|
|
28
|
+
extras = languages.get("extras") or []
|
|
29
|
+
prefix = "Dry run — nothing saved\n" if languages.get("dry_run") else ""
|
|
30
|
+
lines = [f"{prefix}Main language: {_name(languages['main'], languages)}"]
|
|
31
|
+
if extras:
|
|
32
|
+
lines.append("Also translated into: " + ", ".join(_name(code, languages) for code in extras))
|
|
33
|
+
lines.append("New articles are translated into these languages.")
|
|
34
|
+
else:
|
|
35
|
+
lines.append("No extra languages. Add one with: recala company languages add <code>")
|
|
36
|
+
if languages.get("max_extra") is not None:
|
|
37
|
+
left = max(0, int(languages["max_extra"]) - len(extras))
|
|
38
|
+
lines.append(f"{left} more extra language{'' if left == 1 else 's'} allowed.")
|
|
39
|
+
site = [code for code in languages.get("site_languages") or [] if code not in extras]
|
|
40
|
+
if site:
|
|
41
|
+
lines.append("Found on your website: " + ", ".join(_name(code, languages) for code in site))
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.group()
|
|
46
|
+
def languages() -> None:
|
|
47
|
+
"""The languages your articles are translated into.
|
|
48
|
+
|
|
49
|
+
Adding a language translates new articles into it. Changing it needs an owner session."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@languages.command("show")
|
|
53
|
+
@common_options
|
|
54
|
+
@command_runner(human=describe_languages)
|
|
55
|
+
def show(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
56
|
+
"""Show the main language and the extra languages."""
|
|
57
|
+
with refusals_in_words(not_found=NOT_ENABLED):
|
|
58
|
+
current: dict[str, Any] = open_client(ctx, org_flag).call(LANGUAGES_SHOW)
|
|
59
|
+
return current
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def change_extras(ctx: click.Context, org_flag: str | None, code: str, *, adding: bool, dry_run: bool) -> dict[str, Any]:
|
|
63
|
+
code = code.strip().lower()
|
|
64
|
+
client = open_client(ctx, org_flag)
|
|
65
|
+
with refusals_in_words(not_found=NOT_ENABLED):
|
|
66
|
+
current: dict[str, Any] = client.call(LANGUAGES_SHOW)
|
|
67
|
+
extras = list(current.get("extras") or [])
|
|
68
|
+
known = {item["code"] for item in current.get("supported") or []}
|
|
69
|
+
if adding and known and code not in known:
|
|
70
|
+
raise CliError(ExitCode.USAGE, "unsupported_language",
|
|
71
|
+
f"{code} is not a supported language. Choose one of: {', '.join(sorted(known))}.")
|
|
72
|
+
if adding and code == current.get("main"):
|
|
73
|
+
raise CliError(ExitCode.CONFLICT, "main_language",
|
|
74
|
+
f"{_name(code, current)} is already the main language; extra languages are other ones.")
|
|
75
|
+
if adding and code in extras:
|
|
76
|
+
raise CliError(ExitCode.CONFLICT, "language_unchanged",
|
|
77
|
+
f"New articles are already translated into {_name(code, current)}.")
|
|
78
|
+
if adding and current.get("max_extra") is not None and len(extras) >= int(current["max_extra"]):
|
|
79
|
+
raise CliError(ExitCode.CONFLICT, "language_limit",
|
|
80
|
+
f"This company already has {len(extras)} extra languages, the most it can have. "
|
|
81
|
+
f"Remove one first: recala company languages remove <code>")
|
|
82
|
+
if not adding and code not in extras:
|
|
83
|
+
raise CliError(ExitCode.CONFLICT, "language_unchanged",
|
|
84
|
+
f"{_name(code, current)} is not one of the extra languages "
|
|
85
|
+
f"({', '.join(extras) or 'there are none'}).")
|
|
86
|
+
wanted = extras + [code] if adding else [item for item in extras if item != code]
|
|
87
|
+
if dry_run:
|
|
88
|
+
return {**current, "extras": wanted, "dry_run": True}
|
|
89
|
+
action = "Translate new articles into" if adding else "Stop translating new articles into"
|
|
90
|
+
confirm(ctx, client, f"{action} {_name(code, current)}?")
|
|
91
|
+
with refusals_in_words(not_found=NOT_ENABLED, conflict=CHANGED):
|
|
92
|
+
saved: dict[str, Any] = client.call(LANGUAGES_SET, json={"extras": wanted})
|
|
93
|
+
return saved
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@languages.command("add")
|
|
97
|
+
@common_options
|
|
98
|
+
@command_runner(human=describe_languages)
|
|
99
|
+
@ignored_yes_option
|
|
100
|
+
@click.argument("code")
|
|
101
|
+
@click.option("--dry-run", is_flag=True, help="Show the resulting languages without saving.")
|
|
102
|
+
def add(ctx: click.Context, org_flag: str | None, yes: bool, code: str, dry_run: bool) -> dict[str, Any]:
|
|
103
|
+
"""Add an extra language: new articles are translated into it.
|
|
104
|
+
|
|
105
|
+
CODE is a language code such as en, es, pt, de or fr."""
|
|
106
|
+
return change_extras(ctx, org_flag, code, adding=True, dry_run=dry_run)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@languages.command("remove")
|
|
110
|
+
@common_options
|
|
111
|
+
@command_runner(human=describe_languages)
|
|
112
|
+
@ignored_yes_option
|
|
113
|
+
@click.argument("code")
|
|
114
|
+
@click.option("--dry-run", is_flag=True, help="Show the resulting languages without saving.")
|
|
115
|
+
def remove(ctx: click.Context, org_flag: str | None, yes: bool, code: str, dry_run: bool) -> dict[str, Any]:
|
|
116
|
+
"""Stop translating new articles into a language.
|
|
117
|
+
|
|
118
|
+
Translations already published stay as they are."""
|
|
119
|
+
return change_extras(ctx, org_flag, code, adding=False, dry_run=dry_run)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`recala report monthly`: the monthly client report, and a link to share it.
|
|
2
|
+
|
|
3
|
+
``report monthly`` shows the month; ``report monthly share`` makes a link anyone can open
|
|
4
|
+
(revocable in the portal), so it asks first and is created only on an explicit yes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import calendar
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from recala_cli import config
|
|
17
|
+
from recala_cli.commands.refusals import refusals_in_words
|
|
18
|
+
from recala_cli.display import display_name
|
|
19
|
+
from recala_cli.endpoints import REPORT_MONTHLY, REPORT_SHARE_CREATE
|
|
20
|
+
from recala_cli.errors import CliError, ExitCode
|
|
21
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, yes_option
|
|
22
|
+
from recala_cli.search_numbers import counted
|
|
23
|
+
|
|
24
|
+
NOT_ENABLED = "Monthly reports are not switched on for this company yet."
|
|
25
|
+
NO_SECTIONS = ("This month has nothing to report yet (no search, answer, publishing or authority data), "
|
|
26
|
+
"so there is nothing to share. Try a month with articles published.")
|
|
27
|
+
_MONTH = re.compile(r"(\d{4})-(0[1-9]|1[0-2])")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def this_month() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).strftime("%Y-%m")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def valid_month(value: str | None) -> str:
|
|
35
|
+
month = value or this_month()
|
|
36
|
+
if not _MONTH.fullmatch(month):
|
|
37
|
+
raise click.BadParameter("Use YYYY-MM, for example 2026-10.", param_hint="--month")
|
|
38
|
+
return month
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def month_title(month: str) -> str:
|
|
42
|
+
return f"{calendar.month_name[int(month[5:7])]} {month[:4]}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _value(node: Any) -> Any:
|
|
46
|
+
return node.get("value") if isinstance(node, dict) else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _change(node: Any) -> str:
|
|
50
|
+
change = _value((node or {}).get("change")) if isinstance(node, dict) else None
|
|
51
|
+
return f" ({change:+g} on the month before)" if isinstance(change, (int, float)) else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _search_lines(search: dict[str, Any]) -> list[str]:
|
|
55
|
+
lines = []
|
|
56
|
+
for key, noun in (("clicks", "click"), ("impressions", "impression")):
|
|
57
|
+
current = _value((search.get(key) or {}).get("current"))
|
|
58
|
+
if current is not None:
|
|
59
|
+
lines.append(f" Google Search: {counted(current, noun)}{_change(search.get(key))}")
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _answer_lines(answers: dict[str, Any]) -> list[str]:
|
|
64
|
+
lines = []
|
|
65
|
+
for engine in (answers.get("engines") or {}).values():
|
|
66
|
+
owned = _value(engine.get("owned"))
|
|
67
|
+
if owned is not None:
|
|
68
|
+
lines.append(f" {engine.get('display_name') or 'An answer engine'}: your site answers {owned} tracked "
|
|
69
|
+
f"question{'' if owned == 1 else 's'}")
|
|
70
|
+
return lines
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe_report(result: dict[str, Any]) -> str:
|
|
74
|
+
report = result.get("report") or {}
|
|
75
|
+
month = str(report.get("month") or "")
|
|
76
|
+
lines = [f"Monthly report: {month_title(month) if _MONTH.fullmatch(month) else month}"]
|
|
77
|
+
headline = (report.get("headline") or {}).get("text")
|
|
78
|
+
if headline:
|
|
79
|
+
lines.append(display_name(headline))
|
|
80
|
+
delivered = report.get("work_delivered") or {}
|
|
81
|
+
published, scheduled = (_value((delivered.get(key) or {}).get("count")) for key in ("published", "scheduled_next"))
|
|
82
|
+
sections = _search_lines(report.get("search") or {}) + _answer_lines(report.get("ai_answers") or {})
|
|
83
|
+
if published is not None:
|
|
84
|
+
sections.append(f" Articles published: {published}")
|
|
85
|
+
if scheduled:
|
|
86
|
+
sections.append(f" Articles scheduled next: {scheduled}")
|
|
87
|
+
authority = report.get("authority") or {}
|
|
88
|
+
if _value(authority.get("latest")) is not None:
|
|
89
|
+
sections.append(f" Authority score: {_value(authority['latest']):g}{_change(authority)}")
|
|
90
|
+
lines.extend(sections or ["Nothing to report for this month yet."])
|
|
91
|
+
lines.append(f"Share it: recala report monthly share --month {month} --yes" if month else "")
|
|
92
|
+
return "\n".join(line for line in lines if line)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MonthlyGroup(click.Group):
|
|
96
|
+
"""`report monthly [--month M]` shows the report; `report monthly share` makes the link."""
|
|
97
|
+
|
|
98
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
99
|
+
if not args or (args[0] not in self.commands and args[0] not in ("--help", "-h")):
|
|
100
|
+
args = ["show", *args]
|
|
101
|
+
return super().parse_args(ctx, args)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@click.group("monthly", cls=MonthlyGroup)
|
|
105
|
+
def monthly() -> None:
|
|
106
|
+
"""The monthly client report and its share link.
|
|
107
|
+
|
|
108
|
+
Search, answers, work delivered and authority. `recala report monthly --month 2026-10` shows it; `recala report monthly share` makes a link to send."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@monthly.command("show", hidden=True)
|
|
112
|
+
@common_options
|
|
113
|
+
@command_runner(human=describe_report)
|
|
114
|
+
@click.option("--month", default=None, help="The month, YYYY-MM (default: this month).")
|
|
115
|
+
def show(ctx: click.Context, org_flag: str | None, month: str | None) -> dict[str, Any]:
|
|
116
|
+
"""Show the report for a month."""
|
|
117
|
+
chosen = valid_month(month)
|
|
118
|
+
with refusals_in_words(not_found=NOT_ENABLED):
|
|
119
|
+
report: dict[str, Any] = open_client(ctx, org_flag).call(REPORT_MONTHLY, params={"month": chosen})
|
|
120
|
+
return report
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def describe_share(result: dict[str, Any]) -> str:
|
|
124
|
+
if result.get("dry_run"):
|
|
125
|
+
return (f"Dry run — would create a link to the {month_title(result['month'])} report. "
|
|
126
|
+
"Anyone with the link could read it; nothing was created.")
|
|
127
|
+
return (f"Link to the {month_title(result['month'])} report (anyone who has it can read it):\n{result['url']}\n"
|
|
128
|
+
"Revoke it from the report page in the portal.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@monthly.command("share")
|
|
132
|
+
@common_options
|
|
133
|
+
@command_runner(human=describe_share)
|
|
134
|
+
@yes_option
|
|
135
|
+
@click.option("--month", default=None, help="The month, YYYY-MM (default: this month).")
|
|
136
|
+
@click.option("--dry-run", is_flag=True, help="Check the report can be shared without creating a link.")
|
|
137
|
+
def share(ctx: click.Context, org_flag: str | None, yes: bool, month: str | None, dry_run: bool) -> dict[str, Any]:
|
|
138
|
+
"""Create a link to the monthly report.
|
|
139
|
+
|
|
140
|
+
Anyone with the link can read the report, so it is made only after you confirm (--yes with a publish key)."""
|
|
141
|
+
chosen = valid_month(month)
|
|
142
|
+
client = open_client(ctx, org_flag)
|
|
143
|
+
if dry_run:
|
|
144
|
+
with refusals_in_words(not_found=NOT_ENABLED):
|
|
145
|
+
report = client.call(REPORT_MONTHLY, params={"month": chosen})
|
|
146
|
+
if not any((report.get("report") or {}).get(name) for name in ("search", "ai_answers", "work_delivered", "authority")):
|
|
147
|
+
raise CliError(ExitCode.CONFLICT, "report_has_no_sections", NO_SECTIONS)
|
|
148
|
+
return {"dry_run": True, "month": chosen}
|
|
149
|
+
confirm(ctx, client, f"Create a link anyone can use to read the {month_title(chosen)} report?", publishes=True, yes=yes)
|
|
150
|
+
with refusals_in_words(not_found=NOT_ENABLED, conflict=NO_SECTIONS):
|
|
151
|
+
created: dict[str, Any] = client.call(REPORT_SHARE_CREATE, json={"month": chosen})
|
|
152
|
+
return {**created, "url": f"{config.web_url()}{created['path']}"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""`recala notifications`: what the portal's bell shows, and marking it read.
|
|
2
|
+
|
|
3
|
+
The API lists the unread notifications of the company, newest first (at most 20).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from recala_cli.display import display_name
|
|
13
|
+
from recala_cli.endpoints import NOTIFICATION_READ, NOTIFICATIONS_LIST, NOTIFICATIONS_READ_ALL
|
|
14
|
+
from recala_cli.errors import CliError, ExitCode
|
|
15
|
+
from recala_cli.ids import is_short_id, matching_prefix
|
|
16
|
+
from recala_cli.runtime import command_runner, common_options, confirm, ignored_yes_option, open_client
|
|
17
|
+
from recala_cli.views import company_timezone, moment_in
|
|
18
|
+
|
|
19
|
+
NEXT_STEP = "Mark them read with: recala notifications read --all"
|
|
20
|
+
#: The API lists at most this many unread notifications and read-all marks every one, so a full list is no count.
|
|
21
|
+
LISTED_AT_MOST = 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def describe_notifications(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
25
|
+
rows = result.get("notifications") or []
|
|
26
|
+
if not rows:
|
|
27
|
+
return "No unread notifications."
|
|
28
|
+
zone = company_timezone(ctx, result)
|
|
29
|
+
lines = [f"{len(rows)} unread notification{'' if len(rows) == 1 else 's'}, newest first:"]
|
|
30
|
+
for row in rows:
|
|
31
|
+
title = display_name(row.get("title")) or "Notification"
|
|
32
|
+
message = display_name(row.get("message"))
|
|
33
|
+
when = moment_in(row.get("created_at"), zone)
|
|
34
|
+
lines.append(f" {title} [{str(row['id'])[:8]}] {when}" + (f"\n {message}" if message else ""))
|
|
35
|
+
lines.append(NEXT_STEP)
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group()
|
|
40
|
+
def notifications() -> None:
|
|
41
|
+
"""The company's notifications."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@notifications.command("list")
|
|
45
|
+
@common_options
|
|
46
|
+
@command_runner(contextual_human=describe_notifications)
|
|
47
|
+
def list_notifications(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
48
|
+
"""Unread notifications, newest first."""
|
|
49
|
+
unread: dict[str, Any] = open_client(ctx, org_flag).call(NOTIFICATIONS_LIST)
|
|
50
|
+
return unread
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe_read(result: dict[str, Any]) -> str:
|
|
54
|
+
if result.get("dry_run"):
|
|
55
|
+
return f"Dry run — would mark {_how_many(result['marked_read'])} as read."
|
|
56
|
+
return f"Marked {_how_many(result['marked_read'])} as read."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _how_many(count: int | None) -> str:
|
|
60
|
+
"""None: the list was full, so the count is unknown (read-all marks every unread one)."""
|
|
61
|
+
if count is None:
|
|
62
|
+
return f"every unread notification ({LISTED_AT_MOST} or more)"
|
|
63
|
+
return f"{count} notification{'' if count == 1 else 's'}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pick_unread(rows: list[dict[str, Any]], selector: str) -> str:
|
|
67
|
+
if not is_short_id(selector) and len(selector) != 36:
|
|
68
|
+
raise CliError(ExitCode.USAGE, "invalid_notification_id",
|
|
69
|
+
f"{selector} is not a notification id. Use the 8-character id from recala notifications list.")
|
|
70
|
+
matches = matching_prefix(rows, selector)
|
|
71
|
+
if len(matches) != 1:
|
|
72
|
+
raise CliError(ExitCode.NOT_FOUND if not matches else ExitCode.USAGE,
|
|
73
|
+
"notification_not_found" if not matches else "notification_ambiguous",
|
|
74
|
+
f"No unread notification starts with {selector}. See: recala notifications list" if not matches
|
|
75
|
+
else f"Several notifications start with {selector}; use more characters.")
|
|
76
|
+
return str(matches[0]["id"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@notifications.command("read")
|
|
80
|
+
@common_options
|
|
81
|
+
@command_runner(human=describe_read)
|
|
82
|
+
@ignored_yes_option
|
|
83
|
+
@click.argument("notification_id", required=False)
|
|
84
|
+
@click.option("--all", "everything", is_flag=True, help="Mark every unread notification as read.")
|
|
85
|
+
@click.option("--dry-run", is_flag=True, help="Say what would be marked without marking it.")
|
|
86
|
+
def read(ctx: click.Context, org_flag: str | None, yes: bool, notification_id: str | None, everything: bool,
|
|
87
|
+
dry_run: bool) -> dict[str, Any]:
|
|
88
|
+
"""Mark one notification (by its 8-character id) or all of them as read."""
|
|
89
|
+
if bool(notification_id) == everything:
|
|
90
|
+
raise click.UsageError("Give one notification id, or --all.")
|
|
91
|
+
client = open_client(ctx, org_flag)
|
|
92
|
+
rows = (client.call(NOTIFICATIONS_LIST) or {}).get("notifications") or []
|
|
93
|
+
chosen = rows if everything else [row for row in rows if row["id"] == pick_unread(rows, notification_id or "")]
|
|
94
|
+
outcome: dict[str, Any] = {"marked_read": len(chosen), "ids": [str(row["id"]) for row in chosen]}
|
|
95
|
+
if everything and len(chosen) >= LISTED_AT_MOST:
|
|
96
|
+
outcome = {"marked_read": None, "at_least": LISTED_AT_MOST, "all_unread": True}
|
|
97
|
+
if dry_run:
|
|
98
|
+
return {**outcome, "dry_run": True}
|
|
99
|
+
if not chosen:
|
|
100
|
+
return outcome
|
|
101
|
+
confirm(ctx, client, f"Mark {_how_many(outcome['marked_read'])} as read?")
|
|
102
|
+
if everything:
|
|
103
|
+
client.call(NOTIFICATIONS_READ_ALL)
|
|
104
|
+
else:
|
|
105
|
+
client.call(NOTIFICATION_READ, path={"notification_id": outcome["ids"][0]})
|
|
106
|
+
return outcome
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Interactive company setup through topics. Non-interactive callers get a read-only checklist."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from recala_cli import auth, config, ui
|
|
10
|
+
from recala_cli.client import RecalaClient
|
|
11
|
+
from recala_cli.commands.auth import sign_in
|
|
12
|
+
from recala_cli.commands.connect import connect_sanity, connect_wordpress
|
|
13
|
+
from recala_cli.commands.org import create_or_fill, scan_website
|
|
14
|
+
from recala_cli.commands.reads import add_person, person_body
|
|
15
|
+
from recala_cli.commands.topics import scout_topics
|
|
16
|
+
from recala_cli.confirm import is_interactive
|
|
17
|
+
from recala_cli.display import display_name, human_data
|
|
18
|
+
from recala_cli.endpoints import CREDITS_SUMMARY, ONBOARDING_COMPLETE
|
|
19
|
+
from recala_cli.errors import CliError, ExitCode
|
|
20
|
+
from recala_cli.onboarding import (
|
|
21
|
+
STEP_KEYS,
|
|
22
|
+
STEPS,
|
|
23
|
+
Facts,
|
|
24
|
+
auto_run_summary,
|
|
25
|
+
checklist_rows,
|
|
26
|
+
evaluate,
|
|
27
|
+
gather,
|
|
28
|
+
mark,
|
|
29
|
+
next_step,
|
|
30
|
+
)
|
|
31
|
+
from recala_cli.output import notify
|
|
32
|
+
from recala_cli.prompts import ask, choose
|
|
33
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option, selected_org
|
|
34
|
+
from recala_cli.views import pay_link_step, credit_lines
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _company(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
38
|
+
memberships = (facts.me or {}).get("memberships") or []
|
|
39
|
+
existing = [m for m in memberships if m.get("website_url")]
|
|
40
|
+
if existing:
|
|
41
|
+
options = [(m["org_id"], f"{m['name']} ({m['website_url']})") for m in existing]
|
|
42
|
+
picked = choose(ctx, [*options, ("new", "A new company")], label="Which company?", flag="--org", default="new")
|
|
43
|
+
if picked != "new":
|
|
44
|
+
config.set_active_org(picked)
|
|
45
|
+
client.org_id = picked
|
|
46
|
+
return
|
|
47
|
+
name = ask(ctx, None, label="Company name", flag="--name")
|
|
48
|
+
website = ask(ctx, None, label="Website (https://...)", flag="--website")
|
|
49
|
+
language = choose(ctx, [("es", "Español (es)"), ("en", "English (en)")],
|
|
50
|
+
label="Article language", flag="--language", default="es")
|
|
51
|
+
market = ask(ctx, None, label="Country it sells in (ES, US, ...)", flag="--market").upper()
|
|
52
|
+
create_or_fill(ctx, client, name=name, website=website, language=language, market=market)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scan(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
56
|
+
scan_website(ctx, client, website=None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _authors(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
60
|
+
notify(" Every article is signed by a named person.")
|
|
61
|
+
name = ask(ctx, None, label="Author's full name", flag="--name")
|
|
62
|
+
role = ask(ctx, None, label="Role (e.g. Founder)", flag="--role")
|
|
63
|
+
link = ask(ctx, None, label="Public bio/profile link (https://...)", flag="--bio-link")
|
|
64
|
+
add_person(ctx, client, person_body(name, role, (link,), "", "", ()))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cms(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
68
|
+
provider = choose(ctx, [("sanity", "Sanity"), ("wordpress", "WordPress")], label="CMS provider",
|
|
69
|
+
flag="--provider", default="sanity")
|
|
70
|
+
if provider == "sanity":
|
|
71
|
+
connect_sanity(ctx, client)
|
|
72
|
+
else:
|
|
73
|
+
connect_wordpress(ctx, client, wordpress_com=click.confirm(" Is it hosted on WordPress.com?", default=False, err=True))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _plan(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
77
|
+
if client is None:
|
|
78
|
+
raise CliError(ExitCode.NOT_AUTHENTICATED, "not_authenticated", "Sign in first: recala login")
|
|
79
|
+
if not client.org_id:
|
|
80
|
+
raise CliError(ExitCode.CONFLICT, "org_not_selected", "Select a company with --org or recala company use <id>.")
|
|
81
|
+
if facts.summary is None or "plan" in facts.refusals:
|
|
82
|
+
try:
|
|
83
|
+
facts.summary = client.call(CREDITS_SUMMARY)
|
|
84
|
+
except CliError as error:
|
|
85
|
+
if error.exit_code == ExitCode.FORBIDDEN:
|
|
86
|
+
raise CliError(ExitCode.FORBIDDEN, error.code,
|
|
87
|
+
"Cannot read the plan. This credential needs the billing:read scope.",
|
|
88
|
+
status=error.status) from error
|
|
89
|
+
if error.exit_code == ExitCode.NOT_FOUND:
|
|
90
|
+
raise CliError(ExitCode.UNAVAILABLE, "plan_unavailable", "The plan could not be read. Try again shortly.") from error
|
|
91
|
+
raise
|
|
92
|
+
if not facts.summary or not facts.summary.get("plan"):
|
|
93
|
+
raise CliError(ExitCode.UNAVAILABLE, "plan_unavailable", "The plan could not be read. Try again shortly.")
|
|
94
|
+
if next(step for step in STEPS if step.key == "plan").check(facts)[0]:
|
|
95
|
+
summary = facts.summary or {}
|
|
96
|
+
name = summary.get("plan_name") or summary.get("plan_key") or summary.get("plan")
|
|
97
|
+
notify(f" plan: {display_name(name)} active")
|
|
98
|
+
return
|
|
99
|
+
raise CliError(ExitCode.CONFLICT, "human_payment_required",
|
|
100
|
+
f"Human payment required: {pay_link_step()}, then run recala onboard again. "
|
|
101
|
+
"This command does not purchase a plan.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _topics(ctx: click.Context, client: RecalaClient, facts: Facts) -> None:
|
|
105
|
+
result = scout_topics(ctx, client)
|
|
106
|
+
notify(f" Scout complete: {result.get('found', 0)} topics found.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
RUNNERS = {"company": _company, "scan": _scan, "authors": _authors, "cms": _cms, "plan": _plan, "topics": _topics}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _client(ctx: click.Context, org_flag: str | None) -> RecalaClient | None:
|
|
113
|
+
if auth.credential_kind() == "none":
|
|
114
|
+
return None
|
|
115
|
+
return open_client(ctx, org_flag, require_org=bool(selected_org(ctx, org_flag)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _report(facts: Facts, *, agent: bool = False) -> dict[str, Any]:
|
|
119
|
+
rows = evaluate(facts, agent=agent)
|
|
120
|
+
return {"org_id": facts.org.get("id"), "company": facts.org.get("name"), "plan": facts.summary,
|
|
121
|
+
"onboarding_completed": bool(facts.org.get("onboarding_completed")),
|
|
122
|
+
"steps": rows, "missing": [row for row in rows if not row["done"]], "next": next_step(rows),
|
|
123
|
+
"done": sum(1 for row in rows if row["done"]), "total": len(rows),
|
|
124
|
+
**({"summary": auto_run_summary(facts)} if agent else {})}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _complete_setup(ctx: click.Context, client: RecalaClient, facts: Facts, *, refill: bool = False) -> None:
|
|
128
|
+
if facts.org.get("onboarding_completed") and not refill:
|
|
129
|
+
return
|
|
130
|
+
confirm(ctx, client, "Complete company setup and fill Up next from the topics?")
|
|
131
|
+
client.token = auth.bearer_token(client)
|
|
132
|
+
client.call(ONBOARDING_COMPLETE)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def describe_onboarding(report: dict[str, Any]) -> str:
|
|
136
|
+
report = human_data(report)
|
|
137
|
+
lines = ["", *credit_lines(report.get("plan") or {}, None, None), "",
|
|
138
|
+
ui.checklist(checklist_rows(report["steps"]), style=ui.Style.for_stream(sys.stdout)), ""]
|
|
139
|
+
upcoming = report.get("next")
|
|
140
|
+
if upcoming:
|
|
141
|
+
lines.append(f" Next: {upcoming['title']} -> {upcoming['command']}")
|
|
142
|
+
lines.append(" Run recala onboard in a terminal to continue. Agents can run each step's command.")
|
|
143
|
+
if not upcoming or (report.get("onboarding_completed") and all(row["key"] == "plan" for row in report["missing"])):
|
|
144
|
+
lines.append(f" Company setup complete: {report.get('company')}. See: recala status")
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_journey(ctx: click.Context, org_flag: str | None, only: str | None) -> dict[str, Any]:
|
|
149
|
+
style = ui.Style.for_stream()
|
|
150
|
+
ui.banner("Let's set up your company on Recala", style=style)
|
|
151
|
+
client = _client(ctx, org_flag)
|
|
152
|
+
if client is None:
|
|
153
|
+
sign_in(ctx, within_onboarding=True)
|
|
154
|
+
client = _client(ctx, org_flag)
|
|
155
|
+
if client is None:
|
|
156
|
+
raise CliError(ExitCode.NOT_AUTHENTICATED, "sign_in_required", "Sign in first: recala login")
|
|
157
|
+
facts = gather(client)
|
|
158
|
+
notify(describe_onboarding(_report(facts)))
|
|
159
|
+
for index, step in enumerate(STEPS, start=1):
|
|
160
|
+
done, _ = step.check(facts)
|
|
161
|
+
if step.key == "login" or (only and step.key != only) or (done and not only):
|
|
162
|
+
continue
|
|
163
|
+
# A completed setup may need billing attention, reported in the checklist.
|
|
164
|
+
# Explicit --step plan still validates billing with its existing exit codes.
|
|
165
|
+
if step.key == "plan" and facts.org.get("onboarding_completed") and not only:
|
|
166
|
+
continue
|
|
167
|
+
notify(f"\nStep {index} of {len(STEPS)} {step.title}")
|
|
168
|
+
RUNNERS[step.key](ctx, client, facts)
|
|
169
|
+
client.token = auth.bearer_token(client)
|
|
170
|
+
facts = gather(client)
|
|
171
|
+
finished, detail = step.check(facts)
|
|
172
|
+
if not finished:
|
|
173
|
+
raise CliError(ExitCode.CONFLICT, "step_incomplete", f"{step.title} is still incomplete. {step.command}")
|
|
174
|
+
notify(f" Done: {step.title}: {display_name(detail)}")
|
|
175
|
+
if step.key == "company":
|
|
176
|
+
notify("\n".join(credit_lines(facts.summary or {}, None, None)))
|
|
177
|
+
report = _report(facts)
|
|
178
|
+
if not report["missing"] and only != "plan" and not facts.org.get("onboarding_completed"):
|
|
179
|
+
_complete_setup(ctx, client, facts)
|
|
180
|
+
return report
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@click.command()
|
|
184
|
+
@common_options
|
|
185
|
+
@command_runner(human=describe_onboarding)
|
|
186
|
+
@click.option("--apply", "apply_step", is_flag=True, help="Apply the complete step, including in JSON mode.")
|
|
187
|
+
@click.option("--step", "only", type=click.Choice((*STEP_KEYS[1:], "complete")), default=None, help="Run just this step (again).")
|
|
188
|
+
@accepted_yes_option
|
|
189
|
+
@dry_run_option
|
|
190
|
+
def onboard(ctx: click.Context, org_flag: str | None, only: str | None, apply_step: bool) -> dict[str, Any]:
|
|
191
|
+
"""Guided setup: company, site, author, CMS, topics.
|
|
192
|
+
|
|
193
|
+
Creates a company (es/en), reads its site, adds the signing author, connects its CMS and scouts topics.
|
|
194
|
+
|
|
195
|
+
Resumable with y/n/a confirmations. A human enters the hidden CMS token and pays for Go/Self-serve separately.
|
|
196
|
+
--json reports state; --step complete --apply saves setup. The report guides an agent through topic approval, cadence and automatic approval.
|
|
197
|
+
Without a terminal, reports missing steps; --step plan checks active billing.
|
|
198
|
+
"""
|
|
199
|
+
return _onboard(ctx, org_flag, only, apply_step)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _onboard(ctx: click.Context, org_flag: str | None, only: str | None, apply_step: bool = False) -> dict[str, Any]:
|
|
203
|
+
agent = bool(ctx.find_root().obj.get("as_json"))
|
|
204
|
+
if apply_step and only != "complete":
|
|
205
|
+
raise CliError(ExitCode.USAGE, "invalid_step", "--apply requires --step complete.")
|
|
206
|
+
if only == "complete" and (apply_step or not agent):
|
|
207
|
+
client = open_client(ctx, org_flag)
|
|
208
|
+
facts = gather(client, agent=apply_step)
|
|
209
|
+
rows = evaluate(facts, agent=apply_step)
|
|
210
|
+
prerequisites = rows[:next(i for i, row in enumerate(rows) if row["key"] == "complete")] if apply_step else rows
|
|
211
|
+
missing = next_step(prerequisites)
|
|
212
|
+
if missing:
|
|
213
|
+
raise CliError(ExitCode.CONFLICT, "setup_incomplete",
|
|
214
|
+
f"{missing['title']} must finish first. Run recala onboard --json for the next action.")
|
|
215
|
+
_complete_setup(ctx, client, facts, refill=apply_step)
|
|
216
|
+
verified = gather(client, agent=apply_step or agent)
|
|
217
|
+
before = _report(facts, agent=True)["next"] if apply_step else None
|
|
218
|
+
after = _report(verified, agent=True)["next"] if apply_step else None
|
|
219
|
+
stalled_refill = (before and after and before["key"] == after["key"] == "autorun"
|
|
220
|
+
and before["command"] == after["command"]
|
|
221
|
+
and ("--step complete" in before["command"] or facts.progress.get("completion_not_saved")))
|
|
222
|
+
if not verified.org.get("onboarding_completed") or stalled_refill:
|
|
223
|
+
message = ("Up next is still empty after refreshing setup; the server did not confirm progress."
|
|
224
|
+
if stalled_refill else
|
|
225
|
+
"The completion command returned but the server still reports onboarding_completed false.")
|
|
226
|
+
from recala_cli.onboarding import completion_state
|
|
227
|
+
mark(str(client.org_id), completion_not_saved=message, completion_state=completion_state(verified))
|
|
228
|
+
raise CliError(ExitCode.CONFLICT, "completion_not_saved", message)
|
|
229
|
+
mark(str(client.org_id), completion_not_saved=None, completion_state=None)
|
|
230
|
+
return _report(verified, agent=agent)
|
|
231
|
+
if is_interactive(ctx) and only != "plan":
|
|
232
|
+
return _run_journey(ctx, org_flag, only)
|
|
233
|
+
client = _client(ctx, org_flag)
|
|
234
|
+
facts = gather(client, agent=agent)
|
|
235
|
+
if only == "plan":
|
|
236
|
+
_plan(ctx, client, facts)
|
|
237
|
+
return _report(facts, agent=agent)
|