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,522 @@
1
+ """Server facts, the terminal checklist and actionable agent onboarding steps."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from shlex import quote
8
+ from typing import Any
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from recala_cli import config
12
+ from recala_cli.parallel import run_parallel
13
+ from recala_cli.client import RecalaClient
14
+ from recala_cli.endpoints import (
15
+ ARTICLES_LIST,
16
+ AUTOAPPROVE_SHOW,
17
+ CADENCE_SHOW,
18
+ CALENDAR,
19
+ CMS_STATUS,
20
+ CREDITS_SUMMARY,
21
+ GSC_STATUS,
22
+ ME,
23
+ ORG_SHOW,
24
+ PEOPLE_LIST,
25
+ PUBLICATION_PLAN,
26
+ QUESTION_PANEL,
27
+ TOPICS_LIST,
28
+ TOPICS_SCOUT_STATUS,
29
+ )
30
+ from recala_cli.errors import CliError, ExitCode
31
+ from recala_cli.runtime import feature_switched_off, optional_read, sanity_or_none
32
+ from recala_cli.views import PAY_LINK_STEP, pay_link_step
33
+
34
+
35
+ @dataclass
36
+ class Facts:
37
+ me: dict[str, Any] | None = None
38
+ org: dict[str, Any] = field(default_factory=dict)
39
+ progress: dict[str, Any] = field(default_factory=dict)
40
+ people: list[dict[str, Any]] = field(default_factory=list)
41
+ cms: dict[str, Any] | None = None
42
+ gsc: dict[str, Any] | None = None
43
+ sanity: dict[str, Any] | None = None
44
+ summary: dict[str, Any] | None = None
45
+ panel: dict[str, Any] = field(default_factory=dict)
46
+ topics: dict[str, Any] = field(default_factory=dict)
47
+ scout: dict[str, Any] = field(default_factory=dict)
48
+ articles: list[dict[str, Any]] = field(default_factory=list)
49
+ calendar: dict[str, Any] = field(default_factory=dict)
50
+ cadence: dict[str, Any] = field(default_factory=dict)
51
+ approval: dict[str, Any] = field(default_factory=dict)
52
+ publication_plan: dict[str, Any] = field(default_factory=dict)
53
+ refusals: dict[str, dict[str, Any]] = field(default_factory=dict)
54
+
55
+
56
+ def gather(client: RecalaClient | None, calendar_window: dict[str, str] | None = None,
57
+ *, agent: bool = False, parallel: bool = False) -> Facts:
58
+ """Everything onboarding and status read. ``parallel`` asks the independent questions at the same time."""
59
+ if client is None:
60
+ return Facts()
61
+ me = client.call(ME)
62
+ if not client.org_id:
63
+ return Facts(me=me)
64
+ reads = {
65
+ "org": lambda: (optional_read(client, ORG_SHOW, org_id=str(client.org_id)) or {}).get("org") or {},
66
+ "people": lambda: [p for p in (optional_read(client, PEOPLE_LIST) or {}).get("people") or []
67
+ if p.get("is_active", True)],
68
+ "cms": lambda: optional_read(client, CMS_STATUS),
69
+ "gsc": lambda: optional_read(client, GSC_STATUS),
70
+ "summary": lambda: optional_read(client, CREDITS_SUMMARY),
71
+ "panel": lambda: optional_read(client, QUESTION_PANEL) or {},
72
+ "topics": lambda: optional_read(client, TOPICS_LIST, params={"limit": 20, "offset": 0}) or {},
73
+ "scout": lambda: optional_read(client, TOPICS_SCOUT_STATUS) or {},
74
+ "articles": lambda: (optional_read(client, ARTICLES_LIST, params={"limit": 50, "offset": 0}) or {}
75
+ ).get("articles") or [],
76
+ "calendar": lambda: _read_calendar(client, calendar_window) if calendar_window else {},
77
+ }
78
+ found = run_parallel(reads) if parallel else {name: read() for name, read in reads.items()}
79
+ facts = Facts(me=me, progress=config.onboarding_progress(str(client.org_id)), **found)
80
+ if (facts.cms or {}).get("provider") == "Sanity":
81
+ facts.sanity = sanity_or_none(client)
82
+ if agent:
83
+ if facts.summary is None:
84
+ facts.summary = _agent_read(client, CREDITS_SUMMARY, facts, "plan")
85
+ facts.cadence = _agent_read(client, CADENCE_SHOW, facts, "cadence")
86
+ facts.approval = _agent_read(client, AUTOAPPROVE_SHOW, facts, "autoapprove")
87
+ if _cadence(facts)[0] and _autoapprove(facts)[0]:
88
+ facts.publication_plan = _agent_read(client, PUBLICATION_PLAN, facts, "autorun")
89
+ if facts.progress.get("completion_not_saved"):
90
+ previous = facts.progress.get("completion_state")
91
+ if (previous is not None and previous != completion_state(facts)) or _autorun(facts)[0]:
92
+ mark(str(client.org_id), completion_not_saved=None, completion_state=None)
93
+ facts.progress.update(completion_not_saved=None, completion_state=None)
94
+ return facts
95
+
96
+
97
+ def completion_state(facts):
98
+ return {"completed": bool(facts.org.get("onboarding_completed")),
99
+ "up_next": sorted(str(row.get("id")) for row in facts.topics.get("up_next") or [])}
100
+
101
+
102
+ def _agent_read(client: RecalaClient, endpoint, facts: Facts, key: str) -> dict[str, Any]:
103
+ try:
104
+ return client.call(endpoint) or {}
105
+ except CliError as error:
106
+ if error.status not in (403, 404) and not feature_switched_off(error):
107
+ raise
108
+ command = {"cadence": "cadence set", "autoapprove": "autoapprove on"}.get(key, "onboard")
109
+ facts.refusals[key] = {"code": error.code, "message": error.message,
110
+ "next": refusal_action(error, command)}
111
+ return {}
112
+
113
+
114
+ def _read_calendar(client: RecalaClient, window: dict[str, str]) -> dict[str, Any]:
115
+ """Scheduling is optional during onboarding: switched off reads as disabled, absent as empty;
116
+ other calendar failures remain errors."""
117
+ try:
118
+ return client.call(CALENDAR, params=window)
119
+ except CliError as error:
120
+ if feature_switched_off(error):
121
+ return {"enabled": False}
122
+ if error.exit_code in (ExitCode.NOT_FOUND, ExitCode.FORBIDDEN):
123
+ return {}
124
+ raise
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Step:
129
+ key: str
130
+ title: str
131
+ command: str
132
+ check: Callable[[Facts], tuple[bool, str]]
133
+
134
+
135
+ def _login(f: Facts) -> tuple[bool, str]:
136
+ return bool(f.me), str(((f.me or {}).get("user") or {}).get("email") or "")
137
+
138
+
139
+ def _company(f: Facts) -> tuple[bool, str]:
140
+ detail = " · ".join(str(f.org[k]) for k in ("name", "website_url", "language") if f.org.get(k))
141
+ return bool(f.org.get("website_url")), detail
142
+
143
+
144
+ def _scan(f: Facts) -> tuple[bool, str]:
145
+ done = bool(f.progress.get("scanned") or (f.org.get("core_description") and f.org.get("niche")))
146
+ return done, "read and confirmed" if done else ""
147
+
148
+
149
+ def _authors(f: Facts) -> tuple[bool, str]:
150
+ return bool(f.people), ", ".join(str(p.get("display_name")) for p in f.people[:3])
151
+
152
+
153
+ def _cms(f: Facts) -> tuple[bool, str]:
154
+ cms = f.cms or {}
155
+ return bool(cms.get("connected")), str(cms.get("provider") or "") if cms.get("connected") else ""
156
+
157
+
158
+ def _plan(f: Facts) -> tuple[bool, str]:
159
+ summary = f.summary or {}
160
+ done = summary.get("plan") == "internal" or (
161
+ summary.get("plan") in {"self_serve", "go", "managed"} and summary.get("status") in {"active", "ending"}
162
+ )
163
+ period = summary.get("period") or {}
164
+ detail = str(summary.get("plan_name") or summary.get("plan_key") or summary.get("plan") or "Plan unavailable")
165
+ if period:
166
+ detail += f": {period.get('allowance')} articles per period, {period.get('left')} left"
167
+ elif summary.get("plan") == "internal":
168
+ detail += ": unlimited"
169
+ return done, detail if done else "Human payment required; billing is managed outside this command"
170
+
171
+
172
+ def _topics(f: Facts) -> tuple[bool, str]:
173
+ total = int(f.topics.get("total") or 0)
174
+ done = total > 0 or f.scout.get("state") == "done"
175
+ detail = f"{total} topics" if total else ("Scout complete; no topics found" if done else str(f.scout.get("error_message") or ""))
176
+ return done, detail
177
+
178
+
179
+ def _agent_plan(facts: Facts) -> tuple[bool, str]:
180
+ allowed = (facts.summary or {}).get("scout_allowed") is True
181
+ legacy_done, detail = _plan(facts)
182
+ if allowed and not legacy_done:
183
+ detail = "Scouting entitlement confirmed"
184
+ return allowed, detail
185
+
186
+
187
+ def _agent_topics(facts: Facts) -> tuple[bool, str]:
188
+ return bool(topic_choices(facts)), _topics(facts)[1]
189
+
190
+
191
+ STEPS = (
192
+ Step("login", "Sign in", "recala login", _login),
193
+ Step("company", "Company", "recala company create --name ... --website ... --language es --market ES", _company),
194
+ Step("scan", "Website scan", "recala company scan", _scan),
195
+ Step("authors", "Author who signs", "recala author add --name ... --role ... --bio-link ...", _authors),
196
+ Step("cms", "Publishing (CMS)", "recala cms connect --provider sanity (or: --provider wordpress)", _cms),
197
+ Step("plan", "Plan", f"Human: {PAY_LINK_STEP}; then recala status", _plan),
198
+ Step("topics", "First topics scout", "recala topics scout", _topics),
199
+ )
200
+ STEP_KEYS = tuple(step.key for step in STEPS)
201
+
202
+
203
+ def evaluate(facts: Facts, *, agent: bool = False) -> list[dict[str, Any]]:
204
+ rows = []
205
+ for step in AGENT_STEPS if agent else STEPS:
206
+ done, detail = step.check(facts)
207
+ if agent and step.key == "authors":
208
+ done = bool(_active_people(facts))
209
+ if agent:
210
+ rows.append({"key": step.key, "title": step.title, "done": done,
211
+ "detail": detail, **agent_action(step.key, facts)})
212
+ continue
213
+ rows.append({"key": step.key, "title": step.title, "done": done, "detail": detail, "command": step.command})
214
+ return rows
215
+
216
+
217
+ def next_step(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
218
+ return next((row for row in rows if not row["done"]), None)
219
+
220
+
221
+ def checklist_rows(rows: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
222
+ upcoming = next_step(rows)
223
+ return [(row["title"], "done" if row["done"] else ("now" if row is upcoming else "next"), row["detail"]) for row in rows]
224
+
225
+
226
+ def mark(org_id: str, **flags: Any) -> None:
227
+ config.save_onboarding_progress(org_id, {**config.onboarding_progress(org_id), **flags})
228
+
229
+
230
+ def topic_choices(facts: Facts) -> list[dict[str, Any]]:
231
+ seen, rows = set(), []
232
+ for section in ("up_next", "backlog", "queued", "processing", "other"):
233
+ for row in facts.topics.get(section) or []:
234
+ if row.get("id") not in seen and row.get("review_decision") != "dismissed":
235
+ seen.add(row.get("id"))
236
+ rows.append(row)
237
+ return rows
238
+
239
+
240
+ def _approve_topics(facts: Facts) -> tuple[bool, str]:
241
+ approved = [row for row in topic_choices(facts) if row.get("review_decision") == "approved"]
242
+ return bool(approved), ", ".join(row["topic"] for row in approved[:3])
243
+
244
+
245
+ def _complete(facts: Facts) -> tuple[bool, str]:
246
+ return bool(facts.org.get("onboarding_completed")), "Company setup saved; Up next filled"
247
+
248
+
249
+ def _cadence(facts: Facts) -> tuple[bool, str]:
250
+ cadence = facts.cadence.get("cadence") or {}
251
+ return bool(cadence.get("days") and cadence.get("local_time") and cadence.get("timezone")
252
+ and cadence.get("autopilot_enabled")), "Publication days, time and auto-run consent saved"
253
+
254
+
255
+ def _signer(facts: Facts) -> dict[str, Any] | None:
256
+ signer_id = facts.approval.get("default_author_person_id")
257
+ return next((person for person in _active_people(facts) if person.get("id") == signer_id), None)
258
+
259
+
260
+ def _active_people(facts: Facts) -> list[dict[str, Any]]:
261
+ return [person for person in facts.people
262
+ if person.get("id") and person.get("display_name") and person.get("is_active", True)]
263
+
264
+
265
+ def _autoapprove(facts: Facts) -> tuple[bool, str]:
266
+ signer = _signer(facts)
267
+ return bool(facts.approval.get("auto_approve_passing") and signer), str((signer or {}).get("display_name") or "")
268
+
269
+
270
+ def _scheduled_slot(facts: Facts) -> dict[str, Any] | None:
271
+ return next((slot for slot in sorted(facts.publication_plan.get("slots") or [],
272
+ key=lambda slot: slot.get("scheduled_at") or "")
273
+ if slot.get("scheduled_at") and slot.get("state") in {
274
+ "would_assign", "bound", "scheduled", "approved"}
275
+ and slot.get("next_state") not in {"blocked", "needs_attention"}
276
+ and slot.get("reason") not in {"active_author_required", "no_signer"}), None)
277
+
278
+
279
+ def _autorun(facts: Facts) -> tuple[bool, str]:
280
+ flags = facts.publication_plan.get("flags") or {}
281
+ ready = all(flags.get(key) is True for key in (
282
+ "cadence_autopilot_enabled", "publish_cadence_enabled", "publication_calendar_enabled",
283
+ "write_ahead_enabled", "auto_approve_passing_enabled"))
284
+ plan_cadence = facts.publication_plan.get("cadence") or {}
285
+ cadence_ready = "cadence" not in facts.publication_plan or plan_cadence.get("autopilot_enabled") is True
286
+ return bool(ready and cadence_ready and not facts.publication_plan.get("entitlement_refusal")
287
+ and _scheduled_slot(facts)), "Server automation enabled and first scheduled slot verified"
288
+
289
+
290
+ AGENT_STEPS = (*(Step(step.key, step.title, step.command,
291
+ {"plan": _agent_plan, "topics": _agent_topics}.get(step.key, step.check)) for step in STEPS),
292
+ Step("approve_topics", "Approve first topics", "recala topics approve {topic_id}", _approve_topics),
293
+ Step("complete", "Save company setup", "recala onboard --step complete", _complete),
294
+ Step("cadence", "Publication cadence", "recala cadence set", _cadence),
295
+ Step("autoapprove", "Automatic approval and signer", "recala autoapprove on", _autoapprove),
296
+ Step("autorun", "Verify auto-run", "recala plan --json", _autorun),
297
+ )
298
+
299
+
300
+ def question(field: str, ask: str, example: Any, default: Any = None) -> dict[str, Any]:
301
+ return {"field": field, "ask": ask, "example": example, "default": default, "required": True}
302
+
303
+
304
+ def _company_action(facts: Facts) -> dict[str, Any]:
305
+ memberships = [member for member in (facts.me or {}).get("memberships") or []
306
+ if member.get("website_url")]
307
+ if not facts.org.get("id") and memberships:
308
+ choices = [{"value": member["org_id"], "label": member["name"]} for member in memberships]
309
+ pick = question("company", "Which existing company should we finish setting up?", choices[0]["label"],
310
+ choices[0]["label"] if len(choices) == 1 else None)
311
+ pick["choices"] = choices
312
+ return {"why": "Select the existing company to resume its setup.", "questions": [pick],
313
+ "command": "recala company use {company} --json",
314
+ "done_when": "The existing company is selected for subsequent onboarding reads."}
315
+ return {"why": "Identify the company, its website and the readers it serves.",
316
+ "questions": [
317
+ question("name", "What is the company's name?", "Cometa", facts.org.get("name")),
318
+ question("website", "What is the company's website?", "https://recala.co", facts.org.get("website_url")),
319
+ question("language", "Which language should articles use?", "es", facts.org.get("language")),
320
+ question("market", "Which country does the company sell in?", "ES",
321
+ facts.progress.get("market") or facts.org.get("country_code")),
322
+ ], "command": "recala company create --name {name} --website {website} --language {language} --market {market} --json",
323
+ "done_when": "The selected company has its confirmed name, website and article language."}
324
+
325
+
326
+ def _author_action(facts: Facts) -> dict[str, Any]:
327
+ return {"why": "Every article needs a real named person to sign it.",
328
+ "questions": [
329
+ question("author_name", "Who will sign the articles? Give their full name.", "Ana Cometa"),
330
+ question("role", "What is that person's role?", "Founder"),
331
+ question("bio_link", "What is their public bio or profile URL?", "https://recala.co"),
332
+ ], "command": "recala author add --name {author_name} --role {role} --bio-link {bio_link} --json",
333
+ "done_when": "The human's named signing person is an active company author."}
334
+
335
+
336
+ def _approval_action(facts: Facts) -> dict[str, Any]:
337
+ choices = [{"value": row["id"], "label": row.get("topic")} for row in topic_choices(facts)]
338
+ pick = question("topic_id", "Which topic should Recala keep? Choose by title; repeat for each topic you want.",
339
+ choices[0]["label"] if choices else "A topic title")
340
+ pick["choices"] = choices
341
+ return {"why": "The person chooses which proposed topics Recala may write.", "questions": [pick],
342
+ "command": "recala topics approve {topic_id} --json",
343
+ "done_when": "At least one topic the person chose has review_decision approved."}
344
+
345
+
346
+ def _cadence_days(facts: Facts) -> str | None:
347
+ existing = (facts.cadence.get("cadence") or {}).get("days")
348
+ days = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
349
+ if existing:
350
+ return ",".join(days[day] for day in existing)
351
+ allowance = ((facts.summary or {}).get("period") or {}).get("allowance")
352
+ if allowance is None or allowance < 5:
353
+ return None # Every weekly weekday can occur five times in a monthly period.
354
+ count = min(7, int(allowance // 5))
355
+ return ",".join(days[day] for day in sorted((0, 3, 1, 4, 2, 5, 6)[:count]))
356
+
357
+
358
+ def _cadence_action(facts: Facts) -> dict[str, Any]:
359
+ cadence = facts.cadence.get("cadence") or {}
360
+ allowance = ((facts.summary or {}).get("period") or {}).get("allowance")
361
+ why = "Choose when Recala should prepare and publish articles within the plan's allowance."
362
+ if allowance is not None and allowance < 5:
363
+ why += f" Weekly slots can exceed {allowance} articles per period; auto-run pauses when credits run out."
364
+ return {"why": why, "questions": [
365
+ question("days", "Which publication weekdays? Use comma-separated mon through sun.", "mon", _cadence_days(facts)),
366
+ question("time", "At what local time should articles appear?", "09:00", cadence.get("local_time")),
367
+ question("tz", "Which IANA timezone should the schedule use?", "Europe/Madrid",
368
+ _cadence_timezone(facts)),
369
+ question("autopilot", "Allow Recala to assign topics and generate articles automatically on this cadence?", True, False),
370
+ ], "command": "recala cadence set --days {days} --time {time} --tz {tz} --autopilot --json",
371
+ "done_when": "Publication days, local time and timezone are saved with autopilot_enabled true."}
372
+
373
+
374
+ def _cadence_timezone(facts: Facts) -> str | None:
375
+ saved = (facts.cadence.get("cadence") or {}).get("timezone")
376
+ if saved:
377
+ return saved
378
+ # The server and organizations column both default to UTC without a person's
379
+ # choice. Only a saved cadence proves UTC is the intended local timezone.
380
+ known = facts.org.get("timezone") or facts.cadence.get("timezone")
381
+ return known if known != "UTC" else None
382
+
383
+
384
+ def _autoapprove_action(facts: Facts) -> dict[str, Any]:
385
+ choices = [{"value": person["id"], "label": person["display_name"]} for person in _active_people(facts)]
386
+ names = ", ".join(str(choice["label"]) for choice in choices)
387
+ author = question("author", "Which named person will sign? Choose by name.", names,
388
+ choices[0]["label"] if len(choices) == 1 else None)
389
+ author["choices"] = choices
390
+ return {"why": "Automatic approval requires the person's consent and an active named signer.",
391
+ "questions": [question("autoapprove", f"Allow passing drafts to be approved automatically, signed by {names}?", True, False), author],
392
+ "command": "recala autoapprove on --author {author} --json",
393
+ "done_when": "auto_approve_passing is true and default_author_person_id identifies an active named person."}
394
+
395
+
396
+ def _autorun_action(facts: Facts) -> dict[str, Any]:
397
+ action = {"why": "Verify server automation and the first planned publication date.",
398
+ "command": "recala plan --json", "done_when": "All automation flags are enabled and the plan has a usable scheduled slot."}
399
+ states = {slot.get(field) for slot in facts.publication_plan.get("slots") or []
400
+ for field in ("state", "reason", "next_state")}
401
+ states.update(facts.publication_plan.get(field) for field in ("entitlement_refusal", "reason"))
402
+ if "cadence" in facts.publication_plan:
403
+ plan_cadence = facts.publication_plan["cadence"]
404
+ if not plan_cadence:
405
+ states.add("missing_cadence")
406
+ elif not plan_cadence.get("autopilot_enabled"):
407
+ states.add("autopilot_off")
408
+ remedies = {
409
+ "missing_cadence": "cadence", "cadence_not_configured": "cadence", "autopilot_off": "cadence",
410
+ "no_approved_topic": "approve_topics", "empty_up_next": "approve_topics",
411
+ "no_signer": "autoapprove", "active_author_required": "autoapprove",
412
+ "publication_destination_missing": "cms", # before not_entitled: the slot states share the refusal
413
+ "not_entitled": "plan", "no_article_credit": "plan",
414
+ "write_ahead_window_missed": "cadence",
415
+ }
416
+ if not _autorun(facts)[0]:
417
+ remedy = next((target for state, target in remedies.items() if state in states), None)
418
+ if remedy == "approve_topics" and _approve_topics(facts)[0]:
419
+ remedy = "complete" # Refresh Up next even after initial onboarding was saved.
420
+ if remedy:
421
+ recovery = agent_action(remedy, facts)
422
+ return {**recovery, "why": "Resolve the publication plan blocker: " +
423
+ ", ".join(sorted(str(state) for state in states if state)),
424
+ "done_when": action["done_when"]}
425
+ if facts.publication_plan and not _autorun(facts)[0]:
426
+ action["human_action"] = {"what": "Ask Recala support to resolve the plan's disabled automation flags or slot blockers; then run recala plan --json.",
427
+ "where": "Recala support, with the recala plan --json report"}
428
+ return action
429
+
430
+
431
+ def agent_action(key: str, facts: Facts) -> dict[str, Any]:
432
+ billing = config.web_url() + "/portal/billing"
433
+ basic = {
434
+ "login": {"why": "Authorize the agent to act for your company.", "command": "recala login",
435
+ "human_action": {"what": "Run the login command and approve its device code in your browser.", "where": "The verification URL printed by recala login"},
436
+ "done_when": "recala whoami --json identifies the signed-in person."},
437
+ "scan": {"why": "Read the company's own site to establish its products, audience and voice.",
438
+ "command": "recala company scan --json", "done_when": "The website scan is saved and its facts are confirmed."},
439
+ "cms": {"why": "Connect the place where signed articles will appear.",
440
+ "questions": [question("provider", "Which CMS do you use: sanity or wordpress?", "sanity",
441
+ str((facts.cms or {}).get("provider") or "").lower() or None)],
442
+ "command": "recala cms connect --provider {provider}",
443
+ "human_action": {"what": "Run this command in your terminal; supply CMS connection details and type the secret only at its hidden prompt, or provide it through the environment or --token-stdin.",
444
+ "where": "Your terminal, using CMS project settings or WordPress application passwords"},
445
+ "done_when": "recala connect status --json reports the CMS connected."},
446
+ "plan": {"why": "An active paid plan supplies scouting and article allowances.", "command": "recala onboard --json",
447
+ "human_action": {"what": "Activate and pay for Go or Self-serve yourself, then rerun the command.", "where": billing},
448
+ "done_when": "The server's billing summary reports scout_allowed true (internal, enabled dogfood or paid entitlement)."},
449
+ "topics": {"why": "Find the first topics from the company's confirmed facts.", "command": "recala topics scout --json",
450
+ "done_when": "The company has topics to review by title."},
451
+ "complete": {"why": "Save completed company setup and fill Up next for scheduling.",
452
+ "command": "recala onboard --step complete --apply --json", "done_when": "The server reports onboarding_completed true."},
453
+ }
454
+ builders = {"company": _company_action, "authors": _author_action,
455
+ "approve_topics": _approval_action, "cadence": _cadence_action,
456
+ "autoapprove": _autoapprove_action, "autorun": _autorun_action}
457
+ action = builders[key](facts) if key in builders else basic[key]
458
+ action.setdefault("questions", [])
459
+ if key == "topics" and facts.scout.get("state") == "done" and not topic_choices(facts):
460
+ action.update(command="recala topics add {topic} --json",
461
+ questions=[question("topic", "No topics were found. What buyer question should Recala cover first?", "How do study cards help learning?")])
462
+ if facts.org.get("id") and key not in {"login", "company"} and " --org " not in action["command"]:
463
+ action["command"] += " --org " + quote(str(facts.org["id"]))
464
+ if key in facts.refusals:
465
+ refusal = facts.refusals[key]
466
+ action.update({field: value for field, value in refusal.items() if field != "next"})
467
+ action.update(refusal["next"])
468
+ if key == "complete" and facts.progress.get("completion_not_saved"):
469
+ error = CliError(ExitCode.CONFLICT, "completion_not_saved", facts.progress["completion_not_saved"])
470
+ recovery = refusal_action(error)
471
+ action.update(code=error.code, message=error.message,
472
+ human_action={**recovery.get("human_action", {}),
473
+ "what": error.message + " Resolve this with Recala support,"
474
+ " then run the command again."})
475
+ return action
476
+
477
+
478
+ def refusal_action(error: CliError, command: str = "onboard") -> dict[str, Any]:
479
+ """One recovery mapping for onboarding and its later write commands."""
480
+ if (error.details or {}).get("next"):
481
+ return error.details["next"]
482
+ if error.exit_code == ExitCode.NOT_AUTHENTICATED:
483
+ return {"command": "recala login", "human_action": {
484
+ "what": "Sign in and approve the device code yourself.",
485
+ "where": "The browser verification URL printed by recala login"}}
486
+ if error.code == "human_payment_required":
487
+ return {"command": "recala onboard --json", "human_action": {
488
+ "what": "Activate and pay for Go or Self-serve yourself, then resume onboarding.",
489
+ "where": pay_link_step()}}
490
+ if error.exit_code in {ExitCode.FORBIDDEN, ExitCode.CONFIRMATION_REQUIRED}:
491
+ return {"command": "recala whoami --json", "human_action": {
492
+ "what": "Ask the company owner to authorize or perform this action; keep the existing credential boundaries.",
493
+ "where": "The company owner's terminal; recala grant records session consent"}}
494
+ if error.code == "completion_not_saved" or "not enabled" in error.message.lower():
495
+ return {"command": "recala onboard --json", "human_action": {
496
+ "what": "Ask Recala support to resolve the unverified setup or disabled server feature, then resume onboarding.",
497
+ "where": "Recala support, with the refusal code and message"}}
498
+ return {"command": {"cadence set": "recala cadence show --json",
499
+ "autoapprove on": "recala autoapprove show --json",
500
+ "topics approve": "recala topics list --json"}.get(command, "recala onboard --json")}
501
+
502
+
503
+ def explain_refusal(error: CliError, command: str) -> None:
504
+ if error.exit_code == ExitCode.USAGE:
505
+ return # CLI input errors already name the required flag; preserve their contract.
506
+ recovery = refusal_action(error, command)
507
+ if "Next:" not in error.message and recovery["command"] not in error.message:
508
+ human = (recovery.get("human_action") or {}).get("what")
509
+ error.message += f" Next: {human + ' ' if human else ''}{recovery['command']}."
510
+ error.details = {**(error.details or {}), "code": error.code,
511
+ "message": error.message, "next": recovery}
512
+
513
+
514
+ def auto_run_summary(facts: Facts) -> str | None:
515
+ if not all(step.check(facts)[0] for step in AGENT_STEPS):
516
+ return None
517
+ slot = _scheduled_slot(facts)
518
+ cadence = facts.cadence["cadence"]
519
+ date = datetime.fromisoformat(slot["scheduled_at"]).astimezone(ZoneInfo(cadence["timezone"]))
520
+ name = _signer(facts)["display_name"]
521
+ return (f"Recala will prepare and automatically approve passing articles for {facts.org.get('name')}, "
522
+ f"signed by {name}, for publication starting {date.isoformat()} ({cadence['timezone']}), within the plan's allowance.")