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,320 @@
1
+ """Authors (the people who sign), the publication calendar and the authority report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from recala_cli.client import RecalaClient
12
+ from recala_cli.ids import resolve_person_id
13
+ from recala_cli.endpoints import AUTHORITY_SCORE, CALENDAR, PUBLICATION_PLAN, PEOPLE_LIST, PERSON_CREATE, PERSON_UPDATE
14
+ from recala_cli.errors import CliError, ExitCode
15
+ from recala_cli.labels import resource_label
16
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option, dry_run_option
17
+ from recala_cli.views import calendar_view, company_timezone, planned_calendar
18
+
19
+
20
+ def _describe_people(result: dict[str, Any]) -> str:
21
+ people = result.get("people") or []
22
+ return "\n".join(f"{p.get('display_name', '')} {p.get('job_title') or ''} [{str(p['id'])[:8]}]".rstrip()
23
+ for p in people) or "No authors yet. Add one: recala authors add --name \"...\" --title \"...\""
24
+
25
+
26
+ def person_body(name: str, title: str, links: tuple[str, ...], linkedin: str, bio: str,
27
+ expertise: tuple[str, ...]) -> dict[str, Any]:
28
+ """POST /api/v1/people's body. The slug is a suggestion; the server folds it to ASCII."""
29
+ slug = re.sub(r"[^\w]+", "-", name.strip().lower()).strip("-") or "author"
30
+ linkedin = linkedin or next((link for link in links if "linkedin.com/" in link), "")
31
+ return {"display_name": name.strip(), "slug": slug, "job_title": title.strip(), "bio": bio.strip(),
32
+ "linkedin_url": linkedin, "same_as": list(dict.fromkeys(links)), "knows_about": list(expertise)}
33
+
34
+
35
+ def add_person(ctx: click.Context, client: RecalaClient, body: dict[str, Any]) -> dict[str, Any]:
36
+ confirm(ctx, client, f"Add {body['display_name']} ({body['job_title'] or 'no title'}) as an author who signs "
37
+ "articles?")
38
+ created: dict[str, Any] = client.call(PERSON_CREATE, json=body)
39
+ return created
40
+
41
+
42
+ @click.group()
43
+ def authors() -> None:
44
+ """The named people who sign the company's articles."""
45
+
46
+
47
+ @authors.command("list")
48
+ @common_options
49
+ @command_runner(human=_describe_people)
50
+ def list_authors(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
51
+ """Every author of the company."""
52
+ people: dict[str, Any] = open_client(ctx, org_flag).call(PEOPLE_LIST)
53
+ return people
54
+
55
+
56
+ @authors.command("add")
57
+ @common_options
58
+ @command_runner(human=lambda r: f"Added {(r.get('person') or {}).get('display_name')} "
59
+ f"({(r.get('person') or {}).get('id')}).")
60
+ @click.option("--name", required=True, help="Full name as it appears on the byline.")
61
+ @click.option("--title", "--role", default="", help="Job title, e.g. \"Founder\".")
62
+ @click.option("--link", "--bio-link", "links", multiple=True, help="A public profile or bio link. Repeat for each.")
63
+ @click.option("--linkedin", default="", help="The LinkedIn profile (else the first --link on linkedin.com).")
64
+ @click.option("--bio", default="", help="One or two sentences.")
65
+ @click.option("--expertise", multiple=True, help="A topic this person knows. Repeat for each.")
66
+ @accepted_yes_option
67
+ @dry_run_option
68
+ def add_author(ctx: click.Context, org_flag: str | None, name: str, title: str, links: tuple[str, ...], linkedin: str,
69
+ bio: str, expertise: tuple[str, ...]) -> dict[str, Any]:
70
+ """Add a person who signs articles (owners)."""
71
+ client = open_client(ctx, org_flag)
72
+ return add_person(ctx, client, person_body(name, title, links, linkedin, bio, expertise))
73
+
74
+
75
+ def person_changes(name: str | None, title: str | None, links: tuple[str, ...], linkedin: str | None,
76
+ bio: str | None, expertise: tuple[str, ...]) -> dict[str, Any]:
77
+ """PATCH /api/v1/people/{id}'s body: only the fields the caller gave."""
78
+ given = {"display_name": name.strip() if name else None, "job_title": title.strip() if title is not None else None,
79
+ "bio": bio.strip() if bio is not None else None, "linkedin_url": linkedin,
80
+ "same_as": list(dict.fromkeys(links)) or None, "knows_about": list(expertise) or None}
81
+ return {field: value for field, value in given.items() if value is not None}
82
+
83
+
84
+ @authors.command("edit")
85
+ @common_options
86
+ @command_runner(human=lambda r: f"Updated {(r.get('person') or {}).get('display_name')} "
87
+ f"({(r.get('person') or {}).get('id')}).")
88
+ @click.argument("person_id")
89
+ @click.option("--name", default=None, help="Full name as it appears on the byline.")
90
+ @click.option("--title", "--role", default=None, help="Job title, e.g. \"Founder\".")
91
+ @click.option("--link", "--bio-link", "links", multiple=True, help="A public profile or bio link. Replaces the links.")
92
+ @click.option("--linkedin", default=None, help="The LinkedIn profile.")
93
+ @click.option("--bio", default=None, help="One or two sentences.")
94
+ @click.option("--expertise", multiple=True, help="A topic this person knows. Replaces the topics.")
95
+ @accepted_yes_option
96
+ @dry_run_option
97
+ def edit_author(ctx: click.Context, org_flag: str | None, person_id: str, name: str | None, title: str | None,
98
+ links: tuple[str, ...], linkedin: str | None, bio: str | None,
99
+ expertise: tuple[str, ...]) -> dict[str, Any]:
100
+ """Change an author's profile (owners). Only the fields you pass change."""
101
+ body = person_changes(name, title, links, linkedin, bio, expertise)
102
+ if not body:
103
+ raise CliError(ExitCode.USAGE, "nothing_to_change",
104
+ "Pass at least one of --name, --role, --bio-link, --linkedin, --bio, --expertise.")
105
+ client = open_client(ctx, org_flag)
106
+ person_id = resolve_person_id(client, person_id)
107
+ author_label = resource_label(ctx, client, "person", person_id, result_subject=False)
108
+ confirm(ctx, client, f"Change {author_label}: {', '.join(body)}?")
109
+ updated: dict[str, Any] = client.call(PERSON_UPDATE, path={"person_id": person_id}, json=body)
110
+ return updated
111
+
112
+
113
+ def _day(value: datetime | None, default: datetime) -> str:
114
+ return (value.replace(tzinfo=UTC) if value else default).isoformat()
115
+
116
+
117
+ def calendar_window(start: datetime | None = None, end: datetime | None = None) -> dict[str, str]:
118
+ today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
119
+ return {"from": _day(start, today), "to": _day(end, today + timedelta(days=30))}
120
+
121
+
122
+ def in_calendar_window(value: str | None, window: dict[str, str]) -> bool:
123
+ if not value:
124
+ return False
125
+ try:
126
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
127
+ except ValueError:
128
+ return False
129
+ if moment.tzinfo is None:
130
+ moment = moment.replace(tzinfo=UTC)
131
+ return datetime.fromisoformat(window["from"]) <= moment < datetime.fromisoformat(window["to"])
132
+
133
+
134
+ _SLOT_STATES = {
135
+ "bound": "planned",
136
+ "would_assign": "planned",
137
+ "generating": "being written",
138
+ "scheduled": "scheduled",
139
+ "published": "published",
140
+ "cancelled": "cancelled",
141
+ "needs_attention": "needs attention",
142
+ "write_ahead_window_missed": "needs attention",
143
+ "dispatching": "publishing",
144
+ "unknown": "publication outcome unknown",
145
+ "blocked": "needs attention: publication is blocked",
146
+ "not_entitled": "needs attention: the plan or article allowance does not allow writing",
147
+ "empty_up_next": "waiting for a topic. Add one: recala topics up-next add <title>",
148
+ "would_schedule": "ready to schedule",
149
+ "would_approve_and_schedule": "ready for automatic approval and scheduling",
150
+ }
151
+ _SLOT_REASONS = {
152
+ "write_ahead_window_missed": "it was not written in time",
153
+ "article_held": "the draft was held by the checks",
154
+ "generation_failed": "writing failed",
155
+ "generation_outcome_unknown": "the writing outcome is unknown",
156
+ "slot_missed": "the publication time has passed",
157
+ "auto_approval_disabled": "automatic approval is off",
158
+ "appointment_moved": "the publication appointment was moved",
159
+ "calendar_slot_taken": "the publication time is already taken",
160
+ "booking_unconfirmed": "the publication appointment was not confirmed",
161
+ "no_ready_topic": (
162
+ "no topic in your queue has enough of your own material yet. "
163
+ "Answer the material questions or add sources"
164
+ ),
165
+ "publication_destination_missing": "no publishing site is connected. Connect one: recala cms connect",
166
+ }
167
+ _WRITING_ACTIONS = {
168
+ "would_generate": "ready to write",
169
+ "daily_cap_reached": "waiting for the daily writing allowance to reset",
170
+ "billing_inactive": "billing is inactive",
171
+ "plan_inactive": "the plan is inactive",
172
+ "managed_monthly_cap_reached": "the monthly article allowance is used up",
173
+ "no_article_credit": "no article credit is available",
174
+ "entitlement_unavailable": "the article allowance could not be checked",
175
+ "publication_destination_missing": "no publishing site is connected. Connect one: recala cms connect",
176
+ }
177
+
178
+ # The actions that are refusals, not progress: what a not_entitled slot may name as its cause.
179
+ _WRITING_REFUSALS = _WRITING_ACTIONS.keys() - {"would_generate", "daily_cap_reached"}
180
+
181
+
182
+ def _needs_attention(slot: dict[str, Any]) -> bool:
183
+ return (slot.get("state") in ("needs_attention", "write_ahead_window_missed")
184
+ or slot.get("next_state") == "needs_attention")
185
+
186
+
187
+ def _split_missed_window(reason: Any) -> tuple[Any, str]:
188
+ code, _, cause = str(reason or "").partition(":")
189
+ if code.strip() == "write_ahead_window_missed" and cause.strip():
190
+ return code.strip(), cause.strip()
191
+ return reason, ""
192
+
193
+
194
+ def describe_slot_state(slot: dict[str, Any], cadence: dict[str, Any]) -> str:
195
+ """One human description shared by the calendar and read-only plan."""
196
+ state = slot.get("state")
197
+ reason = slot.get("reason") or slot.get("last_error")
198
+ if state == "write_ahead_window_missed":
199
+ reason = state
200
+ # The deadline keeps the refusal that blocked the slot: "write_ahead_window_missed: monthly_budget_reached".
201
+ reason, cause = _split_missed_window(reason)
202
+ if _needs_attention(slot):
203
+ detail = _SLOT_REASONS.get(reason, f"reason: {str(reason).replace('_', ' ')}" if reason else "check this slot")
204
+ if cause:
205
+ detail += f" ({cause.replace('_', ' ')})"
206
+ prefix = "" if state in _SLOT_STATES else f"state: {str(state).replace('_', ' ')}; "
207
+ description = f"{prefix}needs attention: {detail}."
208
+ if slot.get("article_id"):
209
+ return description + f" See: recala article show {str(slot['article_id'])[:8]}"
210
+ if reason == "write_ahead_window_missed" and slot.get("topic_id"):
211
+ return description + f" Write it now: recala article create --topic {str(slot['topic_id'])[:8]}"
212
+ if slot.get("escalated_at") and "generation_outcome_unknown" not in str(reason):
213
+ return description + f" Replace its topic: recala topics replace-slot {str(slot['id'])[:8]} <topic>"
214
+ return description
215
+
216
+ if state == "not_entitled" and slot.get("generation_action") in _WRITING_REFUSALS:
217
+ return f"needs attention: {_WRITING_ACTIONS[slot['generation_action']]}" # the refusal, not a generic "plan"
218
+ description = _SLOT_STATES.get(state, f"state: {str(state or 'not recorded').replace('_', ' ')}")
219
+ next_state = slot.get("next_state")
220
+ if next_state and next_state != state:
221
+ return description + "; " + _SLOT_STATES.get(next_state, f"state: {str(next_state).replace('_', ' ')}")
222
+ if state in ("bound", "would_assign") and not slot.get("article_id"):
223
+ action = slot.get("generation_action")
224
+ if action in (None, "wait_write_ahead"):
225
+ hours = cadence.get("write_ahead_hours") or 48
226
+ ahead = "about two days" if hours == 48 else f"{hours} hours"
227
+ return f"{description} (written {ahead} ahead)"
228
+ return description + "; " + _WRITING_ACTIONS.get(action, f"reason: {str(action).replace('_', ' ')}")
229
+ return description
230
+
231
+
232
+ def _before_window_end(value: str | None, window: dict[str, str]) -> bool:
233
+ return in_calendar_window(value, {"from": "0001-01-01T00:00:00+00:00", "to": window["to"]})
234
+
235
+
236
+ def _calendar_candidates(result: dict[str, Any]) -> list[dict[str, Any]]:
237
+ """Appointments retain their title/byline even outside the candidate page."""
238
+ by_article = {row.get("article_id"): row for row in result.get("candidates") or []}
239
+ topics = {
240
+ row["article_id"]: row.get("topic") or row.get("title")
241
+ for row in result.get("slots") or [] if row.get("article_id")
242
+ }
243
+ for appointment in result.get("appointments") or []:
244
+ article_id = appointment.get("article_id")
245
+ candidate = by_article.get(article_id) or {}
246
+ by_article[article_id] = {
247
+ **candidate,
248
+ "article_id": article_id,
249
+ "title": candidate.get("title") or appointment.get("title")
250
+ or topics.get(article_id) or "(untitled article)",
251
+ "author_name": candidate.get("author_name") or appointment.get("author_name"),
252
+ }
253
+ return list(by_article.values())
254
+
255
+
256
+ def describe_calendar(ctx: click.Context, result: dict[str, Any]) -> str:
257
+ """Adapt only display rows; JSON retains the plan's original rows and fields."""
258
+ cadence = ctx.meta["calendar_cadence"]
259
+ window = ctx.meta["calendar_window"]
260
+ # A stopped or waiting date is not history: it stays until a person acts, as it does in `recala plan`.
261
+ keep_stopped = ctx.meta.get("calendar_default_start", False)
262
+ slots = [slot
263
+ for slot in result["slots"]
264
+ if in_calendar_window(slot.get("scheduled_at"), window)
265
+ or (keep_stopped and _needs_attention(slot) and _before_window_end(slot.get("scheduled_at"), window))
266
+ ]
267
+ zone = company_timezone(ctx, {**result, "cadence": cadence})
268
+ return calendar_view({**result, "slots": slots, "timezone": zone}, cadence=cadence)
269
+
270
+
271
+ @click.command()
272
+ @common_options
273
+ @command_runner(contextual_human=describe_calendar)
274
+ @click.option("--from", "start", type=click.DateTime(["%Y-%m-%d"]), default=None, help="First day (default today).")
275
+ @click.option("--to", "end", type=click.DateTime(["%Y-%m-%d"]), default=None, help="Last day (default in 30 days; 93 at most).")
276
+ def calendar(ctx: click.Context, org_flag: str | None, start: datetime | None, end: datetime | None) -> dict[str, Any]:
277
+ """What publishes when, and what is ready to book.
278
+
279
+ Scheduled publications by day, and what is ready to schedule."""
280
+ client = open_client(ctx, org_flag)
281
+ window = calendar_window(start, end)
282
+ scheduled = client.call(CALENDAR, params=window)
283
+ plan = optional_read(client, PUBLICATION_PLAN, params={"weeks": 2}) or {}
284
+ ctx.meta["calendar_window"] = window
285
+ ctx.meta["calendar_default_start"] = start is None
286
+ ctx.meta["calendar_cadence"] = plan.get("cadence") or {}
287
+ return planned_calendar(scheduled, plan)
288
+
289
+
290
+ #: The authority score's parts, in the order a person weighs them, under plain names.
291
+ _AUTHORITY_PARTS = (
292
+ ("geo16", "Content quality (GEO)"), ("aeo", "Answer-engine readiness"), ("seo", "Search (SEO)"),
293
+ ("cluster", "Topic clusters"), ("freshness", "Freshness"), ("aeo_citation", "Citations in AI answers"),
294
+ )
295
+
296
+
297
+ def _score(value: Any) -> str:
298
+ return f"{round(float(value), 1):g}"
299
+
300
+
301
+ def describe_authority(result: dict[str, Any]) -> str:
302
+ total = result.get("total_score")
303
+ lines = [f"Authority score: {_score(total) if total is not None else 'not scored yet'}"]
304
+ lines += [f" {name}: {_score(result[key])}" for key, name in _AUTHORITY_PARTS
305
+ if isinstance(result.get(key), (int, float))]
306
+ return "\n".join(lines)
307
+
308
+
309
+ @click.group()
310
+ def report() -> None:
311
+ """Reports on the company's authority."""
312
+
313
+
314
+ @report.command("authority")
315
+ @common_options
316
+ @command_runner(human=lambda r: describe_authority(r))
317
+ def authority(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
318
+ """The authority score and its parts."""
319
+ score: dict[str, Any] = open_client(ctx, org_flag).call(AUTHORITY_SCORE)
320
+ return score
@@ -0,0 +1,138 @@
1
+ """`recala refresh`: the company's auto-refresh of published articles.
2
+
3
+ The API has the company-level summary (read-only: the portal shows the toggles but no route
4
+ changes them), a per-article pause and resume, and the status of one refresh run. There is no
5
+ route that lists runs, so ``refresh runs`` takes the run's id.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+ from recala_cli.commands.refusals import refusals_in_words
15
+ from recala_cli.display import display_name
16
+ from recala_cli.endpoints import ARTICLE_AUTO_REFRESH, REFRESH_STATUS, REFRESH_SUMMARY
17
+ from recala_cli.ids import resolve_article_id, validate_id
18
+ from recala_cli.labels import resource_label
19
+ from recala_cli.runtime import command_runner, common_options, confirm, ignored_yes_option, open_client
20
+ from recala_cli.views import company_timezone, moment_in
21
+
22
+ RUN_NOT_FOUND = "No refresh run has that id for this company. Runs are started by a refresh from an article page."
23
+ NEXT_STEP = "Pause or resume one article: recala refresh pause|resume <article-id>"
24
+
25
+
26
+ def euros(cents: Any) -> str:
27
+ return f"{int(cents or 0) / 100:.2f} EUR"
28
+
29
+
30
+ def describe_summary(summary: dict[str, Any]) -> str:
31
+ state = "on" if summary.get("auto_refresh_enabled") else "off"
32
+ lines = [f"Auto-refresh is {state} for this company."]
33
+ if summary.get("auto_refresh_enabled"):
34
+ per_day = int(summary.get("auto_refresh_per_day") or 0)
35
+ lines.append(f"Each published article is checked every {summary.get('auto_refresh_interval_days')} days, "
36
+ f"up to {per_day} article{'' if per_day == 1 else 's'} a day, "
37
+ f"within {euros(summary.get('monthly_cap_cents'))} a month.")
38
+ lines.append(f"{summary.get('articles_eligible_now', 0)} of {summary.get('articles_total', 0)} published "
39
+ f"articles are due a refresh now.")
40
+ lines.append(f"This month: {summary.get('refreshes_this_month', 0)} refreshes, "
41
+ f"{euros(summary.get('spent_this_month_cents'))} spent.")
42
+ lines.append(NEXT_STEP)
43
+ return "\n".join(lines)
44
+
45
+
46
+ @click.group()
47
+ def refresh() -> None:
48
+ """Auto-refresh of published articles.
49
+
50
+ The company-level switch is changed in the portal; here you read it and pause or resume single articles."""
51
+
52
+
53
+ @refresh.command("show")
54
+ @common_options
55
+ @command_runner(human=describe_summary)
56
+ def show(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
57
+ """Auto-refresh setting, what is due, spend.
58
+
59
+ Whether it is on, how often it runs, what is due now and what it cost this month."""
60
+ summary: dict[str, Any] = open_client(ctx, org_flag).call(REFRESH_SUMMARY)
61
+ return summary
62
+
63
+
64
+ def describe_toggle(ctx: click.Context, result: dict[str, Any]) -> str:
65
+ verb = "paused" if result.get("auto_refresh_paused") else "on"
66
+ prefix = "Dry run — would set" if result.get("dry_run") else "Auto-refresh set to"
67
+ return f"{prefix} {verb} for {ctx.meta['result_subject']}." + (
68
+ " Manual refreshes still work." if result.get("auto_refresh_paused") else "")
69
+
70
+
71
+ def set_paused(ctx: click.Context, org_flag: str | None, article_id: str, *, paused: bool, dry_run: bool) -> dict[str, Any]:
72
+ validate_id(article_id, "article")
73
+ client = open_client(ctx, org_flag)
74
+ full_id = resolve_article_id(client, article_id)
75
+ label = resource_label(ctx, client, "article", full_id)
76
+ if dry_run:
77
+ return {"dry_run": True, "article_id": full_id, "auto_refresh_paused": paused}
78
+ confirm(ctx, client, f"{'Pause' if paused else 'Resume'} auto-refresh for {label}?")
79
+ with refusals_in_words(not_found="No article with that id in this company. Find it with: recala article list"):
80
+ changed: dict[str, Any] = client.call(ARTICLE_AUTO_REFRESH, path={"article_id": full_id},
81
+ json={"auto_refresh_paused": paused})
82
+ return changed
83
+
84
+
85
+ @refresh.command("resume")
86
+ @common_options
87
+ @command_runner(contextual_human=describe_toggle)
88
+ @ignored_yes_option
89
+ @click.argument("article_id")
90
+ @click.option("--dry-run", is_flag=True, help="Say what would change without changing it.")
91
+ def resume(ctx: click.Context, org_flag: str | None, yes: bool, article_id: str, dry_run: bool) -> dict[str, Any]:
92
+ """Resume auto-refresh for one article."""
93
+ return set_paused(ctx, org_flag, article_id, paused=False, dry_run=dry_run)
94
+
95
+
96
+ @refresh.command("pause")
97
+ @common_options
98
+ @command_runner(contextual_human=describe_toggle)
99
+ @ignored_yes_option
100
+ @click.argument("article_id")
101
+ @click.option("--dry-run", is_flag=True, help="Say what would change without changing it.")
102
+ def pause(ctx: click.Context, org_flag: str | None, yes: bool, article_id: str, dry_run: bool) -> dict[str, Any]:
103
+ """Pause auto-refresh for one article.
104
+
105
+ You can still refresh it by hand."""
106
+ return set_paused(ctx, org_flag, article_id, paused=True, dry_run=dry_run)
107
+
108
+
109
+ def describe_run(ctx: click.Context, run: dict[str, Any]) -> str:
110
+ zone = company_timezone(ctx, run)
111
+ status = str(run.get("pipeline_status") or "unknown").replace("_", " ")
112
+ kind = "automatic" if run.get("trigger_type") == "auto" else "manual"
113
+ lines = [f"Refresh {str(run['refresh_id'])[:8]} ({kind}): {status}"
114
+ + (f", {run['progress_pct']}% done" if not run.get("is_terminal") and run.get("progress_pct") is not None else "")]
115
+ lines.append(f"Started {moment_in(run.get('started_at'), zone)}"
116
+ + (f"; finished {moment_in(run['completed_at'], zone)}" if run.get("completed_at") else ""))
117
+ if run.get("is_terminal"):
118
+ lines.append(f"{run.get('sections_updated') or 0} of {run.get('sections_audited') or 0} sections updated"
119
+ + (f"; new grade {run['new_evaluation_grade']}" if run.get("new_evaluation_grade") else ""))
120
+ elif run.get("estimated_seconds_remaining") is not None:
121
+ lines.append(f"About {max(1, round(int(run['estimated_seconds_remaining']) / 60))} minute(s) left.")
122
+ if run.get("error_message"):
123
+ lines.append(f"It failed: {display_name(run['error_message'])}")
124
+ return "\n".join(lines)
125
+
126
+
127
+ @refresh.command("runs")
128
+ @common_options
129
+ @command_runner(contextual_human=describe_run)
130
+ @click.argument("refresh_id")
131
+ def runs(ctx: click.Context, org_flag: str | None, refresh_id: str) -> dict[str, Any]:
132
+ """Status of one refresh run.
133
+
134
+ Progress, sections updated and the new grade. The API has no list of runs, so give the run's id."""
135
+ client = open_client(ctx, org_flag)
136
+ with refusals_in_words(not_found=RUN_NOT_FOUND):
137
+ run: dict[str, Any] = client.call(REFRESH_STATUS, path={"refresh_id": refresh_id})
138
+ return run
@@ -0,0 +1,20 @@
1
+ """Say a refusal in words: the API's bare "Not Found" or 409 becomes the sentence a person can act on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+
8
+ from recala_cli.errors import CliError
9
+
10
+
11
+ @contextmanager
12
+ def refusals_in_words(*, not_found: str | None = None, conflict: str | None = None) -> Iterator[None]:
13
+ """Re-raise a 404 or 409 from the API with our sentence; every other failure passes through unchanged."""
14
+ try:
15
+ yield
16
+ except CliError as error:
17
+ sentence = {404: not_found, 409: conflict}.get(error.status or 0)
18
+ if not sentence:
19
+ raise
20
+ raise CliError(error.exit_code, error.code, sentence, status=error.status, details=error.details) from error
@@ -0,0 +1,47 @@
1
+ """`recala schema`: the command tree and the exit codes, as data, for agents to read before acting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from recala_cli import __version__
10
+ from recala_cli.errors import ExitCode
11
+ from recala_cli.runtime import command_runner, json_option
12
+
13
+
14
+ def _walk(group: click.Group, prefix: str = "") -> list[dict[str, Any]]:
15
+ commands = []
16
+ for name, command in sorted(group.commands.items()):
17
+ full = f"{prefix}{name}"
18
+ if isinstance(command, click.Group):
19
+ commands.extend(_walk(command, f"{full} "))
20
+ continue
21
+ commands.append({
22
+ "name": full,
23
+ "help": (command.help or "").strip().splitlines()[0] if command.help else "",
24
+ "arguments": [p.name for p in command.params if isinstance(p, click.Argument)],
25
+ "options": [
26
+ {"flags": list(p.opts), "required": p.required, "help": getattr(p, "help", "") or ""}
27
+ for p in command.params if isinstance(p, click.Option) and not p.hidden
28
+ ],
29
+ })
30
+ return commands
31
+
32
+
33
+ @click.command()
34
+ @json_option
35
+ @command_runner(human=lambda s: "\n".join([c["name"] for c in s["commands"]] + ["", "Add --json for every flag and exit code."]))
36
+ def schema(ctx: click.Context) -> dict[str, Any]:
37
+ """Every command, flag and exit code.
38
+
39
+ Add --json: it prints each command with its arguments and flags, and what each exit code means.
40
+ Without it, only the command names."""
41
+ root = ctx.find_root().command
42
+ assert isinstance(root, click.Group)
43
+ return {
44
+ "version": __version__,
45
+ "exit_codes": {str(int(code)): code.name for code in ExitCode},
46
+ "commands": _walk(root),
47
+ }
@@ -0,0 +1,81 @@
1
+ """`recala completion` and `recala update`: the two commands about the program itself, not a company."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Callable
8
+
9
+ import click
10
+ from click.shell_completion import get_completion_class
11
+
12
+ from recala_cli.errors import CliError, ExitCode
13
+ from recala_cli.runtime import command_runner, json_option
14
+
15
+ SHELLS = ("bash", "zsh", "fish")
16
+ UV_UPGRADE = ["uv", "tool", "install", "--force", "recala-cli"]
17
+ PIPX_UPGRADE = ["pipx", "install", "--force", "recala-cli"]
18
+
19
+
20
+ @click.command()
21
+ @click.argument("shell", type=click.Choice(SHELLS))
22
+ @click.pass_context
23
+ def completion(ctx: click.Context, shell: str) -> None:
24
+ """Print the tab-completion script for your shell.
25
+
26
+ \b
27
+ Bash: add this line to ~/.bashrc
28
+ eval "$(recala completion bash)"
29
+ Zsh: add this line to ~/.zshrc
30
+ eval "$(recala completion zsh)"
31
+ Fish: save the script once
32
+ recala completion fish > ~/.config/fish/completions/recala.fish
33
+
34
+ Open a new terminal afterwards. Completion needs no sign-in and makes no request.
35
+ """
36
+ root = ctx.find_root().command
37
+ script = get_completion_class(shell)(root, {}, "recala", "_RECALA_COMPLETE").source()
38
+ click.echo(script)
39
+
40
+
41
+ def installed_by(prefix: str) -> str | None:
42
+ """"uv" or "pipx" when this program lives in the private environment of that tool; else None."""
43
+ parts = prefix.replace("\\", "/").split("/")
44
+ if "tools" in parts and "uv" in parts:
45
+ return "uv"
46
+ if "venvs" in parts and "pipx" in parts:
47
+ return "pipx"
48
+ return None
49
+
50
+
51
+ def _run_command(command: list[str]) -> int:
52
+ try:
53
+ # The installer's own summary goes to stderr: stdout carries only Recala's answer (one JSON document with --json).
54
+ return subprocess.run(command, check=False, stdout=sys.stderr.fileno()).returncode
55
+ except FileNotFoundError:
56
+ return 127
57
+
58
+
59
+ @click.command()
60
+ @json_option
61
+ @click.option("--dry-run", is_flag=True, help="Only print the command that would run.")
62
+ @command_runner(human=lambda result: result["message"])
63
+ def update(ctx: click.Context, dry_run: bool) -> dict[str, object]:
64
+ """Install the newest CLI with uv or pipx.
65
+
66
+ Uses the tool that installed Recala. When the installer cannot be recognised, it prints the exact command instead of guessing."""
67
+ seams = ctx.find_root().obj
68
+ manager = installed_by(seams.get("install_prefix") or sys.prefix)
69
+ command = {"uv": UV_UPGRADE, "pipx": PIPX_UPGRADE}.get(manager or "")
70
+ if command is None:
71
+ return {"updated": False, "command": None, "message":
72
+ f"Recala was not installed with uv or pipx, so it will not guess. Run one of:\n"
73
+ f" {' '.join(UV_UPGRADE)}\n {' '.join(PIPX_UPGRADE)}"}
74
+ text = " ".join(command)
75
+ if dry_run:
76
+ return {"updated": False, "command": text, "message": f"Would run: {text}"}
77
+ run: Callable[[list[str]], int] = seams.get("run_command") or _run_command
78
+ click.echo(f"Running: {text}", err=True)
79
+ if run(command) != 0:
80
+ raise CliError(ExitCode.ERROR, "update_failed", f"The update did not finish. Run it yourself: {text}")
81
+ return {"updated": True, "command": text, "message": "Recala is up to date. Run `recala --version` to see which."}