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
recala_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Recala from the terminal: drive a company by hand or through an AI agent."""
2
+
3
+ __version__ = "0.6.0"
recala_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from recala_cli.main import cli
2
+
3
+ cli()
@@ -0,0 +1,372 @@
1
+ """Short status reads and actionable outcomes for article work that outlives a request."""
2
+
3
+ import re
4
+ from datetime import UTC, datetime, timedelta
5
+ from shlex import quote
6
+
7
+ from recala_cli.display import display_name
8
+ from recala_cli.endpoints import ARTICLE_SHOW, GENERATION_STATUS, CADENCE_SHOW
9
+ from recala_cli.errors import CliError, ExitCode
10
+ from recala_cli.human import describe_fields, finding_sentences, recheck_findings
11
+ from recala_cli.polling import poll_until
12
+ from recala_cli.views import company_timezone, moment_in
13
+
14
+
15
+ def hold_text(hold, article_id=None, *, ctx=None):
16
+ """Render the C1 explanation and its one supported recovery path for a person."""
17
+ if not hold:
18
+ return ""
19
+ identifier = str(article_id or "")[:8]
20
+ if hold.get("recognised") is False:
21
+ return ("Held for a reason Recala cannot explain yet. "
22
+ f"Contact support with the article id {identifier}.")
23
+ explanation = display_name(hold.get("explanation"))
24
+ lines = [f"Held: {display_name(hold.get('title')) or 'The article needs attention'}"]
25
+ if explanation:
26
+ lines.append(explanation)
27
+ detail = str(hold.get("detail") or "").strip()
28
+ if detail and display_name(detail) != explanation:
29
+ lines.append("Why:")
30
+ for index, text in enumerate(_hold_findings(hold.get("code") or "", detail), 1):
31
+ lines.append(f"{index}. {text}")
32
+ codes = list(dict.fromkeys(hold.get("codes") or []))
33
+ primary = hold.get("code") or (codes[0] if codes else None)
34
+ others = [code.replace("_", " ").lower() for code in codes if code != primary]
35
+ if others:
36
+ lines.append("Also held for: " + ", ".join(others))
37
+ recheck = hold.get("recheck")
38
+ if recheck is not None:
39
+ zone = company_timezone(ctx, hold) if ctx is not None else hold.get("timezone")
40
+ when = moment_in(recheck.get("at"), zone) or "date not recorded"
41
+ verdict = "passed" if recheck.get("cleared") else "still blocked"
42
+ lines.append(f"Last check: {when}, {verdict}")
43
+ next_action = _hold_next_action(hold, identifier)
44
+ if next_action:
45
+ lines.append(f"Next: {next_action}")
46
+ return "\n".join(lines)
47
+
48
+
49
+ #: Whole sentences joined with "; " (a method review's findings); a "; " after other text stays inside one finding.
50
+ _JOINED_SENTENCES = re.compile(r"(?<=[.!?»”])\s*;\s+")
51
+
52
+
53
+ def _hold_findings(code, detail):
54
+ """One line per finding the API listed (on "- " lines, or as whole sentences joined by "; ")."""
55
+ if "\n- " in "\n" + detail:
56
+ texts = [finding["text"] for finding in recheck_findings(code, detail, {}, split_message=True)]
57
+ else:
58
+ texts = [sentence for part in _JOINED_SENTENCES.split(detail) for sentence in finding_sentences(part)]
59
+ return [" ".join(text.removeprefix("- ").split()) for text in texts if text.strip()]
60
+
61
+
62
+ def _hold_next_action(hold, identifier):
63
+ """Only offer commands whose recovery operation exists in this CLI."""
64
+ next_step = display_name(hold.get("next_step"))
65
+ action = hold.get("action")
66
+ command = ""
67
+ if identifier:
68
+ identifier = quote(identifier)
69
+ if action in {"regenerate", "requeue_composition"}:
70
+ command = f"recala article regenerate {identifier}"
71
+ elif action == "approve":
72
+ command = f"recala article approve {identifier}"
73
+ elif action == "edit":
74
+ command = (f"recala article edit {identifier} --body-file <file> "
75
+ f"&& recala article recheck {identifier}")
76
+ if action in {"swap_product", "recheck_brand"}:
77
+ command = "(in the portal)"
78
+ return " ".join(value for value in (next_step, command) if value)
79
+
80
+
81
+ def _json_hold_message(hold, article_id=None):
82
+ """Preserve the existing JSON error.message bytes; human views use C1 above."""
83
+ if not hold:
84
+ return ""
85
+ code = hold.get("code") or ", ".join(hold.get("codes") or []) or "article_held"
86
+ detail = hold.get("detail") or describe_fields(hold.get("reasons") or {})
87
+ explanation = hold.get("explanation") if hold.get("recognised", True) else None
88
+ text = f"Held ({code}): " + " — ".join(str(v) for v in (detail, explanation) if v)
89
+ action = hold.get("action") or ""
90
+ if article_id:
91
+ identifier = quote(str(article_id))
92
+ if action in {"regenerate", "requeue_composition"} or "re-run" in text.lower():
93
+ text += f"\nNext: recala article regenerate {identifier}"
94
+ elif action in {"edit", "recheck_brand", "swap_product"}:
95
+ text += f"\nNext: recala article edit {identifier} --body-file <file>; recala article recheck {identifier}"
96
+ elif action == "approve":
97
+ text += f"\nNext: recala article approve {identifier}"
98
+ return text
99
+
100
+
101
+ #: The research allowance's stop: no re-run can change it until the ledger's day resets.
102
+ RESEARCH_QUOTA_STOP = "method_search_quota_ledger_blocked"
103
+
104
+
105
+ def ensure_outcome(result, client=None, *, ctx=None):
106
+ """Exit 6 for holds/failures; exit 8 when a research allowance stopped the run."""
107
+ quota_evidence = [
108
+ result.get("failure_code"),
109
+ result.get("failure_reason"),
110
+ result.get("errors"),
111
+ result.get("hold"),
112
+ result.get("error_message"),
113
+ result.get("recovery"),
114
+ ]
115
+ if RESEARCH_QUOTA_STOP in str(quota_evidence):
116
+ zone = result.get("timezone")
117
+ if not zone and client:
118
+ try:
119
+ zone = (client.call(CADENCE_SHOW).get("cadence") or {}).get("timezone")
120
+ except CliError:
121
+ pass
122
+ # The research ledger's day is UTC, independent of publication cadence.
123
+ reset = result.get("retry_at") or result.get("resets_at")
124
+ if not reset:
125
+ reset = (
126
+ datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
127
+ + timedelta(days=1)
128
+ ).isoformat()
129
+ raise CliError(
130
+ ExitCode.RATE_LIMITED,
131
+ RESEARCH_QUOTA_STOP,
132
+ "Daily research limit reached; try again after " + moment_in(reset, zone),
133
+ details=result,
134
+ )
135
+ # "skipped" is a CMS push that never ran (auto-publish off), a normal result;
136
+ # only the run's own state can end as skipped.
137
+ states = {
138
+ result.get("state"),
139
+ result.get("cms_status") if result.get("cms_status") != "skipped" else None,
140
+ result.get("cms_publish_status") if result.get("cms_publish_status") != "skipped" else None,
141
+ }
142
+ failed_states = states & {"held", "failed", "skipped"}
143
+ state = (
144
+ "held" if "held" in states else next(iter(failed_states), result.get("state"))
145
+ )
146
+ if (
147
+ result.get("success") is False
148
+ or state in {"held", "failed", "skipped"}
149
+ or result.get("hold")
150
+ ):
151
+ if ctx is not None and ctx.obj.get("as_json"):
152
+ message = _json_hold_message(result.get("hold"), result.get("article_id"))
153
+ else:
154
+ message = hold_text(result.get("hold"), result.get("article_id"), ctx=ctx)
155
+ if not message:
156
+ cause = ": ".join(str(result[key]) for key in ("failure_code", "failure_reason") if result.get(key))
157
+ message = f"Article generation failed ({cause})." if cause else "Article generation failed: " + describe_fields(
158
+ result.get("errors") or result.get("error_message") or state)
159
+ queue_id = result.get("queue_id") or result.get("run_id")
160
+ if queue_id and client and not result.get("article_id"):
161
+ message += f"\nStart again: {restart_command(client, str(queue_id))}"
162
+ raise CliError(
163
+ ExitCode.CONFLICT,
164
+ "article_held"
165
+ if result.get("hold") or state == "held"
166
+ else "generation_failed",
167
+ message,
168
+ details=result,
169
+ )
170
+ return result
171
+
172
+
173
+ def resume_command(client, identifier, *, article=False):
174
+ return f"recala article {'status' if article else 'run'} {quote(identifier)} --org {quote(client.org_id)}"
175
+
176
+
177
+ def restart_command(client, identifier):
178
+ return f"recala article create --topic {quote(identifier)} --org {quote(client.org_id)}"
179
+
180
+
181
+ def wait_for_article(ctx, client, identifier, *, regeneration=False, regenerate_id=None):
182
+ next_command = resume_command(client, identifier, article=regeneration)
183
+
184
+ def read():
185
+ if regeneration:
186
+ detail = client.call(ARTICLE_SHOW, path={"article_id": identifier})
187
+ article = detail.get("article") or {}
188
+ return {
189
+ **article,
190
+ "article_id": identifier,
191
+ "hold": detail.get("hold"),
192
+ "state": _regeneration_state(article.get("cms_publish_status")),
193
+ "title": article.get("article_title") or article.get("topic"),
194
+ **({"recovery": detail["recovery"]} if detail.get("recovery") else {}),
195
+ }
196
+ result = client.call(GENERATION_STATUS, path={"queue_id": identifier})
197
+ result.setdefault("queue_id", identifier)
198
+ result["state"] = result.get("state") or result.get("status")
199
+ if result["state"] in {"completed", "published", "failed"} and result.get(
200
+ "article_id"
201
+ ):
202
+ detail = client.call(
203
+ ARTICLE_SHOW, path={"article_id": result["article_id"]}
204
+ )
205
+ article = detail.get("article") or {}
206
+ result.update(
207
+ hold=detail.get("hold"),
208
+ title=article.get("article_title") or article.get("topic"),
209
+ cms_status=article.get("cms_publish_status"),
210
+ grade=article.get("evaluation_grade") or None, # the API stores "" for an article it never graded
211
+ )
212
+ if article.get("cms_publish_status") == "held":
213
+ result["state"] = "held"
214
+ return result
215
+
216
+ def finished(result):
217
+ state = result.get("state")
218
+ if not state:
219
+ raise CliError(
220
+ ExitCode.UNAVAILABLE,
221
+ "unreadable_run",
222
+ "The API did not report the run's state.",
223
+ )
224
+ return state not in {
225
+ "regenerating",
226
+ "processing",
227
+ "queued",
228
+ "running",
229
+ }
230
+
231
+ try:
232
+ result = poll_until(
233
+ ctx,
234
+ read,
235
+ finished,
236
+ label="Waiting for article",
237
+ timeout_seconds=7200,
238
+ describe=lambda r: (
239
+ f"{r.get('title') or r.get('topic') or 'Article'}: {r.get('current_node') or r.get('state')}"
240
+ ),
241
+ )
242
+ except KeyboardInterrupt:
243
+ raise CliError(
244
+ ExitCode.UNAVAILABLE,
245
+ "run_continues",
246
+ f"The run continues. Check it with: {next_command}",
247
+ details={"run_id": identifier, "next": {"command": next_command}},
248
+ ) from None
249
+ except CliError as error:
250
+ error.message += f" Check the existing run with: {next_command}."
251
+ raise
252
+ if not regeneration and result.get("state") in {"pending", "interrupted", "cancelled", "canceled"}:
253
+ command = restart_command(client, identifier)
254
+ raise CliError(
255
+ ExitCode.UNAVAILABLE, "run_interrupted",
256
+ f"The run is not running (reset or interrupted). Start again: {command}",
257
+ details={**result, "run_id": identifier, "next": {"command": command}},
258
+ )
259
+ if not regeneration and not _ended_readably(result):
260
+ raise CliError(
261
+ ExitCode.UNAVAILABLE, "unexpected_run_state",
262
+ f"The run ended as '{result.get('state')}' without a readable article. "
263
+ f"Check it with: {next_command}",
264
+ details={**result, "run_id": identifier, "next": {"command": next_command}},
265
+ )
266
+ if regeneration:
267
+ ensure_rerun_ran(result, regenerate_id, ctx=ctx)
268
+ return ensure_outcome(result, client, ctx=ctx)
269
+
270
+
271
+ def failed_rerun(recovery):
272
+ """The article's receipt of a re-run that stopped before replacing its draft, or None."""
273
+ return recovery if isinstance(recovery, dict) and recovery.get("status") == "failed" else None
274
+
275
+
276
+ def ensure_rerun_ran(result, regenerate_id, *, ctx=None):
277
+ """Exit 6 when this re-run stopped before replacing the draft: the draft's old hold is not its outcome.
278
+
279
+ A receipt naming another run is an earlier one; without a run id (an older API) it is this run's,
280
+ because a re-run that writes a new draft replaces the receipt. A research-allowance stop is left
281
+ to ``ensure_outcome``, which says when the allowance resets."""
282
+ recovery = failed_rerun(result.get("recovery"))
283
+ if (recovery is None or recovery.get("run_id") not in (None, regenerate_id)
284
+ or RESEARCH_QUOTA_STOP in (recovery.get("refusal_codes") or [])):
285
+ return result
286
+ refused = recovery.get("code") == "rerun_refused"
287
+ as_json = ctx is not None and ctx.obj.get("as_json")
288
+ raise CliError(ExitCode.CONFLICT, "rerun_refused" if refused else "rerun_failed",
289
+ rerun_stop_text(recovery, result.get("hold"), str(result.get("article_id") or ""),
290
+ full_ids=as_json),
291
+ details=result)
292
+
293
+
294
+ def rerun_stop_text(recovery, hold, article_id, *, full_ids=False):
295
+ """What stopped, why, what still holds the draft, and one next step; agents get the ids in full."""
296
+ refused = recovery.get("code") == "rerun_refused"
297
+ identifier = quote(article_id if full_ids else article_id[:8])
298
+ command = f"recala article regenerate {identifier}"
299
+ message = display_name(recovery.get("message")) or "The re-run stopped. Your previous draft is unchanged."
300
+ if full_ids:
301
+ return f"{message}\nNext: {command}"
302
+ lines = [message]
303
+ if display_name(recovery.get("detail")):
304
+ lines.append(f"Why: {display_name(recovery['detail'])}")
305
+ if hold:
306
+ lines.append(f"Still held: {display_name(hold.get('title')) or 'the article needs attention'}")
307
+ lines.append(f"Next: {_refused_rerun_step(recovery.get('refusal_codes') or [], identifier)}, then run again: {command}"
308
+ if refused else f"Next: try again later: {command}")
309
+ return "\n".join(lines)
310
+
311
+
312
+ def _refused_rerun_step(codes, identifier):
313
+ """What changes the method's answer: the run in progress ending, an author to sign it, or material it can cite."""
314
+ if "method_run_in_flight" in codes:
315
+ return "a run of this article is already in progress; wait for it to finish"
316
+ if "commission_author_missing" in codes:
317
+ return (f"sign it with an active author (recala authors list, then recala article person {identifier} "
318
+ "--person <person>)")
319
+ return "add material Recala can cite (recala knowledge add-url <url> or recala knowledge add <file>)"
320
+
321
+
322
+ def _ended_readably(result):
323
+ """Success needs an article; failed, held and skipped runs carry their own cause."""
324
+ state = result.get("state")
325
+ if state in {"failed", "held", "skipped"}:
326
+ return True
327
+ return state in {"completed", "published"} and bool(result.get("article_id"))
328
+
329
+
330
+ def _regeneration_state(cms_publish_status):
331
+ """The article's CMS status is the regeneration's state; a skipped push is not an outcome."""
332
+ return "draft" if cms_publish_status == "skipped" else cms_publish_status
333
+
334
+
335
+ def describe_run(result, *, ctx=None):
336
+ prefix = (
337
+ f"Charge mode: {result['charge_mode']}\n" if result.get("charge_mode") else ""
338
+ )
339
+ title = result.get("title") or result.get("topic") or "The article"
340
+ if result.get("article_id"):
341
+ title += f" [{str(result['article_id'])[:8]}]"
342
+ if result.get("hold"):
343
+ return prefix + title + "\n" + hold_text(result["hold"], result.get("article_id"), ctx=ctx)
344
+ if result.get("state") in {"processing", "queued", "running"}:
345
+ return (
346
+ prefix
347
+ + f"Started: {title} (run {result.get('run_id')}).\nCheck: {result.get('check_command')}"
348
+ )
349
+ if result.get("state") == "published" or result.get("cms_status") == "published":
350
+ outcome = "published."
351
+ elif "skipped" in (result.get("cms_status"), result.get("cms_publish_status")):
352
+ outcome = "Ready for review; not pushed to the CMS."
353
+ else:
354
+ outcome = "ready for review."
355
+ if outcome == "published.":
356
+ return prefix + f"{title}: {outcome}"
357
+ return prefix + f"{title}: {outcome}" + review_next_step(result)
358
+
359
+
360
+ def review_next_step(result):
361
+ """The grade, then the two commands that take a fresh draft to its signer."""
362
+ letter = result.get("grade") or result.get("evaluation_grade") # a re-run reads the article row itself
363
+ grade = f" Grade {letter}." if letter else ""
364
+ if not result.get("article_id"):
365
+ return grade
366
+ identifier = quote(str(result["article_id"])[:8])
367
+ if letter and str(letter)[0].upper() not in "ABC": # below C the lead reads and fixes it; nobody signs it yet
368
+ return (f"{grade}\nNext: below C it is not ready for anyone to sign. Read it with recala article show "
369
+ f"{identifier} --markdown, fix it with recala article edit {identifier} --body-file <file>, which "
370
+ f"keeps it a draft, then run recala article recheck {identifier}")
371
+ return (f"{grade}\nNext: read it with recala article show {identifier} --markdown, "
372
+ f"then hand it over with recala article submit {identifier}")
recala_cli/auth.py ADDED
@@ -0,0 +1,196 @@
1
+ """Credentials: which token a request carries, keeping a session fresh, and signing in.
2
+
3
+ Two kinds of credential reach the API as a Bearer token:
4
+
5
+ * an **API key** (``rcl_live_…`` / ``rcl_test_…``) for agents and CI: set RECALA_API_KEY,
6
+ or paste it with ``recala login --with-token``. Never refreshed; revoked in Settings.
7
+ * a **session** from ``recala login`` (a device code approved in the browser), which
8
+ carries a refresh token and is renewed a minute before it expires.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import platform
15
+ import time
16
+ import uuid
17
+ from collections.abc import Callable
18
+ from contextlib import AbstractContextManager
19
+ from typing import Any
20
+ from urllib.parse import urlsplit
21
+
22
+ from recala_cli import __version__, config
23
+ from recala_cli.client import RecalaClient
24
+ from recala_cli.endpoints import DEVICE_START, DEVICE_TOKEN, TOKEN_REFRESH
25
+ from recala_cli.errors import CliError, ExitCode, session_expired
26
+
27
+ API_KEY_PREFIX = "rcl_"
28
+ _REFRESH_MARGIN_SECONDS = 60
29
+ _SLOW_DOWN_STEP_SECONDS = 5
30
+ _REFUSED_SIGN_IN = {
31
+ "access_denied": "The sign-in was declined in the browser.",
32
+ "expired_token": "The sign-in code expired. Run `recala login` again.",
33
+ }
34
+
35
+
36
+ def bearer_token(client: RecalaClient) -> str:
37
+ """The token for this command: the environment's, else the stored one (refreshed if due)."""
38
+ from_environment = config.environment_token()
39
+ if from_environment:
40
+ return from_environment
41
+ credentials = config.load_credentials()
42
+ if not credentials.get("access_token"):
43
+ raise CliError(
44
+ ExitCode.NOT_AUTHENTICATED, "not_authenticated", "Not signed in. Run `recala login`, or set RECALA_API_KEY."
45
+ )
46
+ if (credentials.get("kind") == "session" or credentials.get("refresh_token")) and _due_for_refresh(credentials):
47
+ credentials = _refresh(client, credentials)
48
+ if credentials.get("kind") == "session" or credentials.get("refresh_token"):
49
+ client.refresh_session = lambda: _recover_session(client, credentials.get("session_id"))
50
+ return str(credentials["access_token"])
51
+
52
+
53
+ def _recover_session(client: RecalaClient, session_id: str | None) -> str:
54
+ credentials = config.load_credentials()
55
+ if (credentials.get("kind") != "session" and not credentials.get("refresh_token")) or credentials.get("session_id") != session_id:
56
+ raise session_expired()
57
+ token = credentials.get("access_token")
58
+ if token and token != client.token:
59
+ return str(token) # another process already rotated the stored session
60
+ return str(_refresh(client, credentials)["access_token"])
61
+
62
+
63
+ def credential_kind() -> str:
64
+ """What this command authenticates with: "api_key", "session" or "access_token"."""
65
+ from_environment = config.environment_token()
66
+ if from_environment:
67
+ return "api_key" if from_environment.startswith(API_KEY_PREFIX) else "access_token"
68
+ credentials = config.load_credentials()
69
+ if credentials.get("kind"):
70
+ return str(credentials["kind"])
71
+ token = str(credentials.get("access_token") or "")
72
+ return ("api_key" if token.startswith(API_KEY_PREFIX) else "access_token") if token else "none"
73
+
74
+
75
+ def session_id() -> str | None:
76
+ """The stored sign-in's local id (an [a]ll grant is bound to it); None for keys and environment tokens."""
77
+ if config.environment_token():
78
+ return None
79
+ credentials = config.load_credentials()
80
+ return None if credentials.get("kind") == "api_key" else credentials.get("session_id")
81
+
82
+
83
+ def store_pasted_token(token: str) -> dict[str, Any]:
84
+ token = token.strip()
85
+ if not token:
86
+ raise CliError(ExitCode.USAGE, "empty_token", "No token on standard input.")
87
+ kind = "api_key" if token.startswith(API_KEY_PREFIX) else "access_token"
88
+ stored: dict[str, Any] = {"kind": kind, "access_token": token}
89
+ if kind != "api_key":
90
+ stored["session_id"] = _new_session_id() # a person's token: an [a]ll answer may be recorded for it
91
+ config.save_credentials(stored)
92
+ config.forget_grant()
93
+ return {"signed_in": True, "kind": kind}
94
+
95
+
96
+ _OS_NAMES = {"Darwin": "macOS", "Linux": "Linux", "Windows": "Windows"}
97
+
98
+
99
+ def client_label() -> str:
100
+ """What the browser shows next to the code ("Recala CLI 0.3.0 on macOS"): the machine's kind, never its name."""
101
+ system = platform.system()
102
+ return f"Recala CLI {__version__} on {_OS_NAMES.get(system, system or 'a computer')}"
103
+
104
+
105
+ def _new_session_id() -> str:
106
+ """Names one sign-in on this machine so an [a]ll grant ends with it. Local only, never sent."""
107
+ return uuid.uuid4().hex
108
+
109
+
110
+ def device_login(
111
+ client: RecalaClient,
112
+ *,
113
+ sleep: Callable[[float], None],
114
+ open_browser: Callable[[str], Any] | None,
115
+ present_code: Callable[[str, str], None],
116
+ waiting: Callable[[], AbstractContextManager[Any]] = contextlib.nullcontext,
117
+ ) -> dict[str, Any]:
118
+ """RFC 8628 device authorization: show a code, let the person approve it, poll for the session."""
119
+ grant = client.call(DEVICE_START, json={"client": client_label()}, authenticated=False)
120
+ present_code(grant["verification_uri"], grant["user_code"])
121
+ if open_browser is not None:
122
+ open_browser(grant.get("verification_uri_complete") or grant["verification_uri"])
123
+ with waiting():
124
+ session = _poll_for_session(client, grant, sleep)
125
+ config.save_credentials({**_session_from(session), "session_id": _new_session_id()})
126
+ config.forget_grant()
127
+ config.set_web_url(_origin(grant["verification_uri"]))
128
+ return {"signed_in": True, "email": (session.get("user") or {}).get("email")}
129
+
130
+
131
+ def _origin(url: str) -> str:
132
+ parts = urlsplit(url)
133
+ return f"{parts.scheme}://{parts.netloc}"
134
+
135
+
136
+ def _poll_for_session(client: RecalaClient, grant: dict[str, Any], sleep: Callable[[float], None]) -> dict[str, Any]:
137
+ interval, waited, lifetime = int(grant.get("interval", 5)), 0, int(grant.get("expires_in", 600))
138
+ while waited < lifetime:
139
+ sleep(interval)
140
+ waited += interval
141
+ try:
142
+ session = client.call(DEVICE_TOKEN, json={"device_code": grant["device_code"]}, authenticated=False)
143
+ except CliError as refusal:
144
+ if refusal.code == "authorization_pending":
145
+ continue
146
+ if refusal.code == "slow_down":
147
+ interval += _SLOW_DOWN_STEP_SECONDS
148
+ continue
149
+ if refusal.code in _REFUSED_SIGN_IN:
150
+ message = _REFUSED_SIGN_IN[refusal.code]
151
+ raise CliError(ExitCode.NOT_AUTHENTICATED, refusal.code, message, refusal.status) from refusal
152
+ raise
153
+ answer: dict[str, Any] = session
154
+ return answer
155
+ raise CliError(ExitCode.NOT_AUTHENTICATED, "expired_token", _REFUSED_SIGN_IN["expired_token"])
156
+
157
+
158
+ def _due_for_refresh(credentials: dict[str, Any]) -> bool:
159
+ expires_at = credentials.get("expires_at")
160
+ return expires_at is not None and time.time() >= float(expires_at) - _REFRESH_MARGIN_SECONDS
161
+
162
+
163
+ def _refresh(client: RecalaClient, credentials: dict[str, Any]) -> dict[str, Any]:
164
+ expired = session_expired()
165
+ if not credentials.get("refresh_token"):
166
+ raise expired
167
+ try:
168
+ session = client.call(TOKEN_REFRESH, json={"refresh_token": credentials["refresh_token"]}, authenticated=False)
169
+ except ValueError as invalid_response:
170
+ raise expired from invalid_response
171
+ except CliError as refusal:
172
+ if refusal.exit_code in (ExitCode.NOT_AUTHENTICATED, ExitCode.INVALID):
173
+ expired.status = refusal.status
174
+ raise expired from refusal
175
+ raise
176
+ if not isinstance(session, dict) or not isinstance(session.get("access_token"), str) or not session["access_token"]:
177
+ raise expired
178
+ try:
179
+ renewed = {**credentials, **_session_from(session)}
180
+ except (TypeError, ValueError) as invalid_response:
181
+ raise expired from invalid_response
182
+ config.save_credentials(renewed)
183
+ return renewed
184
+
185
+
186
+ def _session_from(session: dict[str, Any]) -> dict[str, Any]:
187
+ stored: dict[str, Any] = {
188
+ "kind": "session",
189
+ "access_token": session["access_token"],
190
+ "refresh_token": session.get("refresh_token"),
191
+ "expires_at": time.time() + int(session.get("expires_in", 3600)),
192
+ }
193
+ email = (session.get("user") or {}).get("email")
194
+ if email:
195
+ stored["email"] = email
196
+ return stored
recala_cli/client.py ADDED
@@ -0,0 +1,103 @@
1
+ """The one HTTP client: base URL, bearer token, organization header, error mapping.
2
+
3
+ Every command goes through ``RecalaClient.call``. It sends X-Recala-Org-ID, the
4
+ header the API's get_current_user selects a company by, and turns every refusal
5
+ or network failure into a CliError with a stable exit code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from recala_cli import __version__
16
+ from recala_cli.endpoints import ME, Endpoint
17
+ from recala_cli.errors import CliError, DryRunStop, error_from_response, network_failure, session_expired
18
+
19
+
20
+ def _auth_layer_refusal(response: httpx.Response) -> bool:
21
+ if response.status_code != 401:
22
+ return False
23
+ try:
24
+ body = response.json()
25
+ except ValueError:
26
+ return False
27
+ detail = body.get("detail") if isinstance(body, dict) else None
28
+ return isinstance(detail, str) and detail in ("Invalid token", "Invalid user", "Missing Bearer token")
29
+
30
+
31
+ class RecalaClient:
32
+ def __init__(
33
+ self,
34
+ api_url: str,
35
+ *,
36
+ org_id: str | None = None,
37
+ transport: httpx.BaseTransport | None = None,
38
+ timeout: float = 60.0,
39
+ ) -> None:
40
+ self.org_id = org_id
41
+ self.token: str | None = None
42
+ self._me: Any = None # GET /me answered once per command; any write forgets it
43
+ self.refresh_session: Callable[[], str] | None = None
44
+ self.dry_run = False # --dry-run: reads go out, no write ever does
45
+ self._http = httpx.Client(
46
+ base_url=api_url,
47
+ transport=transport,
48
+ timeout=timeout,
49
+ headers={"User-Agent": f"recala-cli/{__version__}"},
50
+ )
51
+
52
+ @property
53
+ def known_me(self) -> Any:
54
+ """GET /me as this command already read it, else None."""
55
+ return self._me
56
+
57
+ def call(
58
+ self,
59
+ endpoint: Endpoint,
60
+ *,
61
+ path: dict[str, str] | None = None,
62
+ params: dict[str, Any] | None = None,
63
+ json: Any = None,
64
+ content: bytes | None = None,
65
+ extra_headers: dict[str, str] | None = None,
66
+ authenticated: bool = True,
67
+ timeout: float | None = None,
68
+ ) -> Any:
69
+ if endpoint is ME and self._me is not None:
70
+ return self._me
71
+ if self.dry_run and endpoint.writes:
72
+ raise DryRunStop(f"send {endpoint.method} {endpoint.url_path(**(path or {}))}")
73
+ headers = dict(extra_headers or {})
74
+ if authenticated:
75
+ headers["Authorization"] = f"Bearer {self.token}"
76
+ if self.org_id and "X-Recala-Org-ID" not in headers:
77
+ headers["X-Recala-Org-ID"] = self.org_id
78
+ for attempt in range(2):
79
+ try:
80
+ response = self._http.request(
81
+ endpoint.method, endpoint.url_path(**(path or {})), params=params, json=json, content=content,
82
+ headers=headers, timeout=httpx.USE_CLIENT_DEFAULT if timeout is None else timeout,
83
+ )
84
+ except httpx.HTTPError as exc:
85
+ raise network_failure(exc, writes=endpoint.writes) from exc
86
+ if _auth_layer_refusal(response) and authenticated:
87
+ expired = session_expired(response.status_code, code=error_from_response(response).code)
88
+ if attempt or self.refresh_session is None:
89
+ raise expired
90
+ try:
91
+ self.token = self.refresh_session()
92
+ except CliError as refusal:
93
+ raise expired from refusal
94
+ headers["Authorization"] = f"Bearer {self.token}"
95
+ continue
96
+ if response.is_error:
97
+ raise error_from_response(response, writes=endpoint.writes)
98
+ answer = response.json() if response.content else None
99
+ if endpoint is ME:
100
+ self._me = answer
101
+ elif endpoint.writes:
102
+ self._me = None
103
+ return answer