recala-cli 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. recala_cli/__init__.py +3 -0
  2. recala_cli/__main__.py +3 -0
  3. recala_cli/article_runs.py +372 -0
  4. recala_cli/auth.py +196 -0
  5. recala_cli/client.py +103 -0
  6. recala_cli/commands/__init__.py +1 -0
  7. recala_cli/commands/article.py +835 -0
  8. recala_cli/commands/auth.py +207 -0
  9. recala_cli/commands/billing.py +76 -0
  10. recala_cli/commands/bing.py +164 -0
  11. recala_cli/commands/cadence.py +217 -0
  12. recala_cli/commands/connect.py +395 -0
  13. recala_cli/commands/credits.py +46 -0
  14. recala_cli/commands/faithful_translations.py +70 -0
  15. recala_cli/commands/gsc.py +79 -0
  16. recala_cli/commands/index.py +293 -0
  17. recala_cli/commands/keys.py +98 -0
  18. recala_cli/commands/knowledge.py +245 -0
  19. recala_cli/commands/languages.py +119 -0
  20. recala_cli/commands/monthly_report.py +152 -0
  21. recala_cli/commands/notifications.py +106 -0
  22. recala_cli/commands/onboard.py +237 -0
  23. recala_cli/commands/org.py +352 -0
  24. recala_cli/commands/profile.py +70 -0
  25. recala_cli/commands/questions.py +246 -0
  26. recala_cli/commands/reads.py +320 -0
  27. recala_cli/commands/refresh.py +138 -0
  28. recala_cli/commands/refusals.py +20 -0
  29. recala_cli/commands/schema.py +47 -0
  30. recala_cli/commands/shell.py +81 -0
  31. recala_cli/commands/skills.py +92 -0
  32. recala_cli/commands/slot_topic.py +94 -0
  33. recala_cli/commands/status.py +153 -0
  34. recala_cli/commands/topics.py +328 -0
  35. recala_cli/config.py +176 -0
  36. recala_cli/confirm.py +137 -0
  37. recala_cli/display.py +40 -0
  38. recala_cli/endpoints.py +258 -0
  39. recala_cli/errors.py +220 -0
  40. recala_cli/human.py +324 -0
  41. recala_cli/ids.py +139 -0
  42. recala_cli/labels.py +74 -0
  43. recala_cli/main.py +129 -0
  44. recala_cli/onboarding.py +522 -0
  45. recala_cli/organizations.py +197 -0
  46. recala_cli/output.py +34 -0
  47. recala_cli/parallel.py +18 -0
  48. recala_cli/polling.py +102 -0
  49. recala_cli/prompts.py +88 -0
  50. recala_cli/runtime.py +216 -0
  51. recala_cli/search_numbers.py +22 -0
  52. recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
  53. recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
  54. recala_cli/skills/recala-cli-contract.md +78 -0
  55. recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
  56. recala_cli/skills/recala-onboard/SKILL.md +25 -0
  57. recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
  58. recala_cli/skills/recala-plan-month/SKILL.md +33 -0
  59. recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
  60. recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
  61. recala_cli/ui.py +260 -0
  62. recala_cli/updates.py +43 -0
  63. recala_cli/views.py +372 -0
  64. recala_cli-0.6.0.dist-info/METADATA +63 -0
  65. recala_cli-0.6.0.dist-info/RECORD +67 -0
  66. recala_cli-0.6.0.dist-info/WHEEL +4 -0
  67. recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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)