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,293 @@
1
+ """`recala index`: is Google indexing the company's pages, and what can be done about it, honestly.
2
+
3
+ What Google allows, and what this command does:
4
+
5
+ * **Read**: Search Console's URL Inspection, per published article (indexed or not, coverage, last crawl, Google's
6
+ canonical against the declared one, mobile) and its sitemaps (health, errors, last read). Recala's server inspects,
7
+ caches for 24 hours and respects the per-property quota; the CLI reads its answers.
8
+ * **Notify**: an IndexNow ping (Bing, Yandex, and ChatGPT search through Bing) for an article, and resubmitting the
9
+ sitemap when the Search Console connection holds the write scope (else Google re-reads it on its own).
10
+ * **Not possible**: Google's "Request indexing" button has no public API for normal pages (the Indexing API is only for
11
+ job postings and livestreams, and using it otherwise breaks Google's rules). Every answer prints the Search Console
12
+ link where the person can press it.
13
+
14
+ The server side is the indexing tracker (its own PR); until it is deployed every route answers 404, and the CLI says
15
+ "Indexing tracker not enabled yet".
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from typing import Any
23
+
24
+ import click
25
+
26
+ from recala_cli.client import RecalaClient
27
+ from recala_cli.endpoints import (
28
+ ARTICLE_INDEXING,
29
+ ARTICLE_INDEXING_SUBMIT,
30
+ ARTICLES_LIST,
31
+ INDEXING_SITEMAPS,
32
+ INDEXING_SITEMAPS_SUBMIT,
33
+ )
34
+ from recala_cli.errors import CliError, ExitCode
35
+ from recala_cli.human import describe_fields
36
+ from recala_cli.ids import id_arguments, resolve_article_id
37
+ from recala_cli.labels import resource_label
38
+ from recala_cli.polling import indicator, open_in_browser
39
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option, dry_run_option, accepted_yes_option, dry_run_option
40
+ from recala_cli.views import company_timezone, local_time, timezone_label
41
+
42
+ NOT_ENABLED = "Indexing tracker not enabled yet on this Recala server."
43
+ REQUEST_INDEXING = ("For a priority crawl in Google, use URL Inspection -> Request indexing in Search Console "
44
+ "(Google has no public API for it):")
45
+ _PUBLISHED = {"published", "updated"}
46
+ _INDEXED = {"PASS", "indexed", "INDEXED"}
47
+ #: Google's URL Inspection verdicts in words. VERDICT_UNSPECIFIED and anything unknown say nothing, so they are hidden.
48
+ _INDEXING_WORDS = {"PASS": "indexed", "PARTIAL": "partly indexed", "FAIL": "not indexed (Google found a problem)",
49
+ "NEUTRAL": "not indexed"}
50
+ _MOBILE_WORDS = {"PASS": "works on phones", "PARTIAL": "works on phones with some problems",
51
+ "FAIL": "does not work well on phones"}
52
+ _PARALLEL_READS = 6
53
+
54
+
55
+ def tracker_off() -> CliError:
56
+ return CliError(ExitCode.CONFLICT, "indexing_not_enabled", NOT_ENABLED, 404)
57
+
58
+
59
+ def route_missing(error: CliError) -> bool:
60
+ """FastAPI's own 404 for a route that is not there (an unknown article answers with its own message)."""
61
+ return error.status == 404 and error.message == "Not Found"
62
+
63
+
64
+ def read_indexing(client: RecalaClient, article_id: str) -> dict[str, Any]:
65
+ try:
66
+ state: dict[str, Any] = client.call(ARTICLE_INDEXING, path={"article_id": article_id})
67
+ except CliError as error:
68
+ if route_missing(error):
69
+ raise tracker_off() from error
70
+ raise
71
+ return state
72
+
73
+
74
+ def published_article_ids(client: RecalaClient, limit: int = 50) -> list[str]:
75
+ listed = client.call(ARTICLES_LIST, params={"limit": limit, "offset": 0}) or {}
76
+ return [str(a["id"]) for a in listed.get("articles") or [] if a.get("cms_publish_status") in _PUBLISHED]
77
+
78
+
79
+ def is_indexed(state: dict[str, Any]) -> bool:
80
+ return str(state.get("verdict") or "") in _INDEXED or str(state.get("indexing_state") or "").lower() == "indexed"
81
+
82
+
83
+ def read_indexing_all(client: RecalaClient, ids: list[str],
84
+ on_read: Callable[[int], None] | None = None) -> list[dict[str, Any]]:
85
+ """Each article's indexing state, read in parallel and returned in the order asked."""
86
+ done = 0
87
+
88
+ def read(article_id: str) -> dict[str, Any]:
89
+ nonlocal done
90
+ state = read_indexing(client, article_id)
91
+ done += 1
92
+ if on_read is not None:
93
+ on_read(done)
94
+ return state
95
+
96
+ with ThreadPoolExecutor(max_workers=_PARALLEL_READS) as pool:
97
+ return list(pool.map(read, ids))
98
+
99
+
100
+ def indexing_summary(client: RecalaClient, limit: int = 20) -> dict[str, Any] | None:
101
+ """For `recala status`: how many published articles Google has indexed (None while the tracker is off). One line
102
+ of a dashboard never takes the dashboard down: any other refusal reads as "unavailable"."""
103
+ try:
104
+ ids = published_article_ids(client)[:limit]
105
+ states = read_indexing_all(client, ids)
106
+ except CliError as error:
107
+ return None if error.code == "indexing_not_enabled" else {"unavailable": error.message}
108
+ return {"published": len(ids), "indexed": sum(1 for s in states if is_indexed(s))}
109
+
110
+
111
+ def _when(value: str | None, zone: str | None, *, empty: str = "-") -> str:
112
+ return local_time(value, zone) if value else empty
113
+
114
+
115
+ def article_lines(state: dict[str, Any], zone: str | None = None) -> list[str]:
116
+ verdict = _INDEXING_WORDS.get(str(state.get("verdict") or ""))
117
+ indexed = "indexed" if is_indexed(state) else verdict or "not indexed"
118
+ lines = [f"{state.get('url')}", f" Google: {indexed}"]
119
+ if state.get("coverage_state"):
120
+ lines.append(f" Coverage: {state['coverage_state']}")
121
+ lines.append(f" Last crawl: {_when(state.get('last_crawl'), zone, empty='never')}")
122
+ google, declared = state.get("google_canonical"), state.get("user_canonical")
123
+ same = google == declared if google and declared else None
124
+ lines.append(f" Canonical: {'Google agrees' if same else ('Google chose ' + str(google)) if google else '-'}"
125
+ + (f" (declared {declared})" if declared and not same else ""))
126
+ mobile = _MOBILE_WORDS.get(str(state.get("mobile") or ""))
127
+ if mobile:
128
+ lines.append(f" Mobile: {mobile}")
129
+ if state.get("indexnow_submitted_at"):
130
+ lines.append(f" IndexNow: sent {_when(state['indexnow_submitted_at'], zone)}")
131
+ lines.append(f" Checked: {_when(state.get('inspected_at'), zone)}"
132
+ f" (next {_when(state.get('next_check_at'), zone)})")
133
+ if not is_indexed(state) and state.get("request_indexing_url"):
134
+ lines.append(f" {REQUEST_INDEXING}\n {state['request_indexing_url']}")
135
+ return lines
136
+
137
+
138
+ def describe_statuses(ctx: click.Context, result: dict[str, Any]) -> str:
139
+ rows = result.get("articles") or []
140
+ if not rows:
141
+ return "No published articles to check yet."
142
+ zone = company_timezone(ctx, result)
143
+ lines = [timezone_label(zone)]
144
+ for state in rows:
145
+ lines.extend(article_lines(state, zone))
146
+ lines.append(f"{sum(1 for s in rows if is_indexed(s))} of {len(rows)} indexed in Google.")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def describe_sitemaps(result: dict[str, Any], zone: str | None = None) -> str:
151
+ sitemaps = result.get("sitemaps") or []
152
+ if not sitemaps:
153
+ return "Search Console holds no sitemap for this site. Submit one: recala index sitemaps submit"
154
+ lines = []
155
+ for sitemap in sitemaps:
156
+ errors, warnings = sitemap.get("errors") or 0, sitemap.get("warnings") or 0
157
+ health = "healthy" if not int(errors) else f"{errors} error(s)"
158
+ read = _when(sitemap.get("last_downloaded") or sitemap.get("last_read"), zone, empty="not yet")
159
+ lines.append(f"{sitemap.get('path')} {health}, {warnings} warning(s)")
160
+ lines.append(f" Last read by Google: {read} Submitted: {_when(sitemap.get('last_submitted'), zone)}"
161
+ + (" (pending)" if sitemap.get("is_pending") else ""))
162
+ return "\n".join(lines)
163
+
164
+
165
+ def describe_sitemaps_in_zone(ctx: click.Context, result: dict[str, Any]) -> str:
166
+ if not result.get("sitemaps"):
167
+ return describe_sitemaps(result)
168
+ zone = company_timezone(ctx, result)
169
+ return f"{timezone_label(zone)}\n{describe_sitemaps(result, zone)}"
170
+
171
+
172
+ @click.group()
173
+ def index() -> None:
174
+ """Indexing in Google, IndexNow and the sitemap.
175
+
176
+ What Google has indexed, IndexNow pings, and the sitemap in Search Console."""
177
+
178
+
179
+ @index.group("sitemaps", invoke_without_command=True)
180
+ @common_options
181
+ @click.pass_context
182
+ def sitemaps(ctx: click.Context, as_json: bool, org_flag: str | None) -> None:
183
+ """The site's sitemaps in Search Console.
184
+
185
+ Health, errors, last read (`submit` resubmits)."""
186
+ if ctx.invoked_subcommand is None:
187
+ ctx.invoke(show_sitemaps, as_json=as_json, org_flag=org_flag)
188
+
189
+
190
+ @sitemaps.command("show")
191
+ @common_options
192
+ @command_runner(contextual_human=describe_sitemaps_in_zone)
193
+ def show_sitemaps(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
194
+ """The site's sitemaps in Search Console.
195
+
196
+ Health, errors and last read."""
197
+ try:
198
+ answer: dict[str, Any] = open_client(ctx, org_flag).call(INDEXING_SITEMAPS)
199
+ except CliError as error:
200
+ if route_missing(error):
201
+ raise tracker_off() from error
202
+ raise
203
+ return answer
204
+
205
+
206
+ @sitemaps.command("submit")
207
+ @common_options
208
+ @command_runner(human=lambda r: r.get("message") or f"Submitted {r.get('feedpath')} to Search Console.")
209
+ @click.option("--feedpath", default=None, help="The sitemap URL (default: the first one Search Console holds).")
210
+ @accepted_yes_option
211
+ @dry_run_option
212
+ def submit_sitemap(ctx: click.Context, org_flag: str | None, feedpath: str | None) -> dict[str, Any]:
213
+ """Resubmit the sitemap to Search Console.
214
+
215
+ Needs the connection's write scope; it offers the upgrade."""
216
+ client = open_client(ctx, org_flag)
217
+ if not feedpath:
218
+ feeds = (optional_read(client, INDEXING_SITEMAPS) or {}).get("sitemaps") or []
219
+ feedpath = feeds[0].get("path") if feeds else None
220
+ if not feedpath:
221
+ raise CliError(ExitCode.USAGE, "missing_input", "Name the sitemap: --feedpath https://…/sitemap.xml")
222
+ confirm(ctx, client, f"Submit {feedpath} to Search Console?")
223
+ try:
224
+ submitted: dict[str, Any] = client.call(INDEXING_SITEMAPS_SUBMIT, json={"feedpath": feedpath})
225
+ except CliError as refusal:
226
+ if route_missing(refusal):
227
+ raise tracker_off() from refusal
228
+ upgrade = (refusal.details or {}).get("upgrade_url")
229
+ if refusal.code != "scope_upgrade_required" or not upgrade:
230
+ raise
231
+ open_in_browser(ctx, upgrade, why="Recala can read Search Console but not submit sitemaps. Allow it here:")
232
+ return {"feedpath": feedpath, "submitted": False, "upgrade_url": upgrade,
233
+ "message": "Google re-reads your sitemap on its own. To submit from Recala, allow the write access "
234
+ "in the page just opened, then run this again."}
235
+ return {"feedpath": feedpath, **submitted}
236
+
237
+
238
+ @index.command("status")
239
+ @common_options
240
+ @command_runner(contextual_human=describe_statuses)
241
+ @click.option("--all-published", is_flag=True, help="Every published article (at most 50).")
242
+ @click.argument("article_ids", nargs=-1)
243
+ @id_arguments(article_ids="article")
244
+ def status(ctx: click.Context, org_flag: str | None, all_published: bool, article_ids: tuple[str, ...]) -> dict[str, Any]:
245
+ """Google's view of each article (cached 24 h).
246
+
247
+ Indexed or not, coverage, last crawl, canonical, mobile."""
248
+ if bool(article_ids) == all_published:
249
+ raise click.UsageError("Name one or more articles, or pass --all-published.")
250
+ client = open_client(ctx, org_flag)
251
+ ids = [resolve_article_id(client, selector) for selector in article_ids] if article_ids else published_article_ids(client)
252
+ with indicator(ctx, f"Reading Google's index for {len(ids)} article(s)", tty_only=True) as spinner:
253
+ progress = (lambda done: spinner.update(f"Reading Google's index: {done}/{len(ids)}")) if spinner else None
254
+ states = read_indexing_all(client, ids, progress)
255
+ if spinner is not None:
256
+ spinner.done(f"Read {len(ids)} article(s)")
257
+ return {"articles": [{"article_id": article_id, **state} for article_id, state in zip(ids, states)]}
258
+
259
+
260
+ def describe_submission(ctx: click.Context, result: dict[str, Any]) -> str:
261
+ from recala_cli.views import localise_instants
262
+
263
+ zone = company_timezone(ctx, result)
264
+ lines = article_lines(result["indexing"], zone) if result.get("indexing") else []
265
+ indexnow = localise_instants(result.get("indexnow") or "sent", zone)[0]
266
+ return "\n".join([*lines, f"IndexNow: {describe_fields(indexnow)}", REQUEST_INDEXING,
267
+ result.get("request_indexing_url") or ""])
268
+
269
+
270
+ @index.command("submit")
271
+ @common_options
272
+ @command_runner(contextual_human=describe_submission)
273
+ @click.argument("article_id")
274
+ @id_arguments(article_id="article")
275
+ @accepted_yes_option
276
+ @dry_run_option
277
+ def submit(ctx: click.Context, org_flag: str | None, article_id: str) -> dict[str, Any]:
278
+ """Ping IndexNow for an article and re-check it.
279
+
280
+ IndexNow reaches Bing, Yandex and ChatGPT search; prints Google's Request-indexing link."""
281
+ client = open_client(ctx, org_flag)
282
+ article_id = resolve_article_id(client, article_id)
283
+ label = resource_label(ctx, client, "article", article_id)
284
+ confirm(ctx, client, f"Tell IndexNow about {label} and re-check it in Google?")
285
+ try:
286
+ answer: dict[str, Any] = client.call(ARTICLE_INDEXING_SUBMIT, path={"article_id": article_id})
287
+ except CliError as refusal:
288
+ if route_missing(refusal):
289
+ raise tracker_off() from refusal
290
+ raise
291
+ state = answer.get("indexing") if isinstance(answer.get("indexing"), dict) else answer
292
+ return {"article_id": article_id, "indexnow": answer.get("indexnow") or answer.get("indexnow_submitted_at"),
293
+ "indexing": state, "request_indexing_url": (state or {}).get("request_indexing_url")}
@@ -0,0 +1,98 @@
1
+ """`recala keys`: organization API keys for agents and CI. A person's session only; a key never manages keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from recala_cli.endpoints import KEY_EVENTS, KEY_REVOKE, KEYS_CREATE, KEYS_LIST
10
+ from recala_cli.labels import resource_label
11
+ from recala_cli.runtime import command_runner, common_options, confirm, ignored_yes_option, open_client, yes_option, dry_run_option
12
+
13
+ SCOPES = ("read", "content:write", "content:edit", "publish", "billing:read", "settings:write")
14
+
15
+
16
+ def _describe_keys(result: dict[str, Any]) -> str:
17
+ rows = [f"{k['id']} {k['prefix']} {k['status']:<8} {','.join(k['scopes'])} {k['name']}" for k in result["keys"]]
18
+ return "\n".join(rows) if rows else "No API keys."
19
+
20
+
21
+ def _describe_created(result: dict[str, Any]) -> str:
22
+ return (f"{result['key']}\n\nThis is the only time the key is shown. Store it in a secret manager.\n"
23
+ f"Scopes: {', '.join(result['scopes'])}. Expires {result['expires_at']}.")
24
+
25
+
26
+ @click.group()
27
+ def keys() -> None:
28
+ """Organization API keys for agents and CI."""
29
+
30
+
31
+ @keys.command("list")
32
+ @common_options
33
+ @command_runner(human=_describe_keys)
34
+ def list_keys(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
35
+ """Owners see every key of the company; others see their own."""
36
+ listed: dict[str, Any] = open_client(ctx, org_flag).call(KEYS_LIST)
37
+ return listed
38
+
39
+
40
+ @keys.command("create")
41
+ @common_options
42
+ @command_runner(human=_describe_created)
43
+ @yes_option
44
+ @click.option("--name", required=True, help="What the key is for, e.g. \"Claude agent\".")
45
+ @click.option("--scope", "scopes", type=click.Choice(SCOPES), multiple=True, required=True, help="Repeat for each scope.")
46
+ @click.option("--expires-days", type=click.IntRange(1, 365), default=90, show_default=True)
47
+ @click.option("--rate", "rate_limit", type=click.IntRange(1, 600), default=60, show_default=True, help="Requests per minute.")
48
+ @click.option("--credit-cap", type=click.IntRange(0, 100), default=5, show_default=True, help="Articles per month.")
49
+ @click.option("--publish-cap", type=click.IntRange(0, 50), default=3, show_default=True,
50
+ help="Articles approved or published per day.")
51
+ @click.option("--sign-as", "signer", default=None, help="Your own email: needed for a key that can approve and publish.")
52
+ @click.option("--person", "people", multiple=True,
53
+ help="With publish: a person (id from recala authors) whose articles this key may approve and publish. Repeat for each.")
54
+ @dry_run_option
55
+ def create_key(ctx: click.Context, org_flag: str | None, yes: bool, name: str, scopes: tuple[str, ...], expires_days: int,
56
+ rate_limit: int, credit_cap: int, publish_cap: int, signer: str | None, people: tuple[str, ...]) -> dict[str, Any]:
57
+ """Create a key. It is printed once; Recala keeps only its hash."""
58
+ body: dict[str, Any] = {
59
+ "name": name, "scopes": list(dict.fromkeys(scopes)), "expires_in_days": expires_days,
60
+ "rate_limit_per_minute": rate_limit, "credit_cap_per_month": credit_cap, "publish_cap_per_day": publish_cap,
61
+ }
62
+ client = open_client(ctx, org_flag)
63
+ if "publish" in scopes:
64
+ confirm(ctx, client, f"Create key \"{name}\"? It will approve and publish the articles of the people you name.",
65
+ publishes=True, yes=yes)
66
+ body["publish_signer"] = signer
67
+ body["publish_person_ids"] = list(dict.fromkeys(people))
68
+ else:
69
+ confirm(ctx, client, f"Create key \"{name}\" with {', '.join(body['scopes'])}?")
70
+ created: dict[str, Any] = client.call(KEYS_CREATE, json=body)
71
+ return created
72
+
73
+
74
+ @keys.command("revoke")
75
+ @common_options
76
+ @command_runner(human=lambda key: f"Revoked {key['prefix']}.")
77
+ @ignored_yes_option
78
+ @click.argument("key_id")
79
+ @dry_run_option
80
+ def revoke_key(ctx: click.Context, org_flag: str | None, yes: bool, key_id: str) -> dict[str, Any]:
81
+ """Revoke a key: its next request is refused."""
82
+ client = open_client(ctx, org_flag)
83
+ label = resource_label(ctx, client, "key", key_id)
84
+ confirm(ctx, client, f"Revoke {label}? Every agent that uses it stops.", yes=yes)
85
+ revoked: dict[str, Any] = client.call(KEY_REVOKE, path={"key_id": key_id})
86
+ return revoked
87
+
88
+
89
+ @keys.command("events")
90
+ @common_options
91
+ @command_runner(human=lambda r: "\n".join(f"{e.get('created_at', '')} {e['event']:<8} {e.get('route') or ''}"
92
+ for e in r["events"]))
93
+ @click.option("--limit", type=click.IntRange(1, 200), default=50, show_default=True)
94
+ @click.argument("key_id")
95
+ def key_events(ctx: click.Context, org_flag: str | None, limit: int, key_id: str) -> dict[str, Any]:
96
+ """The key's audit trail, newest first."""
97
+ events: dict[str, Any] = open_client(ctx, org_flag).call(KEY_EVENTS, path={"key_id": key_id}, params={"limit": limit})
98
+ return events
@@ -0,0 +1,245 @@
1
+ """`recala knowledge`: the company's sources (documents and pages) the articles are written from.
2
+
3
+ An owner's upload is the company's own material (founder, 5 Oct: owner uploads are
4
+ admitted automatically). The server admits it itself when its flag allows; when it
5
+ answers "not admitted", the CLI admits it with the same route the Library's
6
+ "Company's own material" choice uses. Both happen under the one confirmation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ from recala_cli.client import RecalaClient
17
+ from recala_cli.endpoints import (
18
+ KNOWLEDGE_ADMIT,
19
+ KNOWLEDGE_LIST,
20
+ KNOWLEDGE_PAGE,
21
+ KNOWLEDGE_REMOVE,
22
+ KNOWLEDGE_UPLOAD,
23
+ KNOWLEDGE_URL,
24
+ KNOWLEDGE_URLS,
25
+ )
26
+ from recala_cli.errors import CliError, ExitCode
27
+ from recala_cli.ids import resolve_source_id
28
+ from recala_cli.labels import action_sentence, resource_label
29
+ from recala_cli.runtime import command_runner, common_options, confirm, ignored_yes_option, open_client, accepted_yes_option, dry_run_option
30
+
31
+ #: What the API reads (routers/knowledge.py upload): the type travels as Content-Type.
32
+ CONTENT_TYPES = {
33
+ ".pdf": "application/pdf",
34
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ ".md": "text/markdown",
36
+ ".txt": "text/plain",
37
+ ".csv": "text/csv",
38
+ }
39
+ #: The Library's "Company's own material": reviewed before it reaches a published page.
40
+ OWN_MATERIAL = {"provenance_class": "first_party", "publication_permission": "review_only"}
41
+
42
+
43
+ #: How `knowledge admit --as` names the API's provenance classes.
44
+ ADMIT_KINDS = {"own": "first_party", "proposed": "proposed", "researched": "researched"}
45
+ ADMIT_WORDS = {"own": "the company's own material", "proposed": "proposed material", "researched": "researched material"}
46
+
47
+
48
+ def content_type_of(file: Path) -> str:
49
+ content_type = CONTENT_TYPES.get(file.suffix.lower())
50
+ if content_type is None:
51
+ raise click.BadParameter(f"{file.name}: Recala reads {', '.join(sorted(CONTENT_TYPES))} files.", param_hint="FILE")
52
+ return content_type
53
+
54
+
55
+ def admission_state(client: RecalaClient, document_id: str) -> str:
56
+ listed = client.call(KNOWLEDGE_LIST) or {}
57
+ for document in listed.get("documents") or []:
58
+ if str(document.get("id")) == document_id:
59
+ return str(document.get("admission_state") or "unknown")
60
+ return "unknown"
61
+
62
+
63
+ def upload_own_material(client: RecalaClient, file: Path) -> dict[str, Any]:
64
+ """Upload one file and make sure it is admitted as the company's own material. The caller confirmed both."""
65
+ uploaded: dict[str, Any] = client.call(
66
+ KNOWLEDGE_UPLOAD, content=file.read_bytes(),
67
+ extra_headers={"Content-Type": content_type_of(file), "X-Filename": file.name},
68
+ )
69
+ document_id = str(uploaded.get("document_id") or "")
70
+ if not document_id or uploaded.get("status") != "ready":
71
+ return {**uploaded, "admission_state": "not_admitted"}
72
+ state = admission_state(client, document_id)
73
+ if state == "not_admitted":
74
+ try:
75
+ client.call(KNOWLEDGE_ADMIT, path={"doc_id": document_id}, json=OWN_MATERIAL)
76
+ state = admission_state(client, document_id)
77
+ except CliError as refusal: # the upload stands; the person can admit it in the Library
78
+ return {**uploaded, "admission_state": state, "admission_error": refusal.message}
79
+ return {**uploaded, "admission_state": state}
80
+
81
+
82
+ def _describe_upload(result: dict[str, Any]) -> str:
83
+ state = result.get("admission_state")
84
+ admitted = "admitted as the company's own material" if state == "admitted" else f"admission: {state}"
85
+ line = f"{result.get('filename')}: {result.get('status')}, {result.get('chunk_count', 0)} passages, {admitted}"
86
+ return line + (f" ({result['admission_error']})" if result.get("admission_error") else "")
87
+
88
+
89
+ @click.group()
90
+ def knowledge() -> None:
91
+ """Sources: the documents and pages Recala writes from."""
92
+
93
+
94
+ def _describe_sources(listed: dict[str, Any]) -> str:
95
+ """Each source by name, its state and admission in words, its short id last, and the next step when it is not admitted."""
96
+ documents = listed.get("documents") or []
97
+ if not documents:
98
+ return "No sources yet."
99
+ admitted = sum(1 for document in documents if document.get("admission_state") == "admitted")
100
+ lines = [f"{len(documents)} source{'' if len(documents) == 1 else 's'}, {admitted} admitted:"]
101
+ for document in documents:
102
+ states = " · ".join(str(document[key]).replace("_", " ") for key in ("status", "admission_state")
103
+ if document.get(key))
104
+ short_id = str(document["id"])[:8]
105
+ lines.append(f" {document.get('filename') or 'Untitled source'} {states} [{short_id}]")
106
+ if document.get("status") == "ready" and document.get("admission_state") == "not_admitted":
107
+ lines.append(f" Next: recala knowledge admit {short_id}")
108
+ return "\n".join(lines)
109
+
110
+
111
+ @knowledge.command("list")
112
+ @common_options
113
+ @command_runner(human=_describe_sources)
114
+ def list_documents(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
115
+ """Every source, its state and its admission.
116
+
117
+ Each document's processing state and whether it is admitted."""
118
+ listed: dict[str, Any] = open_client(ctx, org_flag).call(KNOWLEDGE_LIST)
119
+ return listed
120
+
121
+
122
+ @knowledge.command("add")
123
+ @common_options
124
+ @command_runner(human=_describe_upload)
125
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
126
+ @accepted_yes_option
127
+ @dry_run_option
128
+ def add_document(ctx: click.Context, org_flag: str | None, file: Path) -> dict[str, Any]:
129
+ """Upload a file as the company's own material.
130
+
131
+ PDF, DOCX, Markdown, text or CSV, 10 MB at most."""
132
+ content_type_of(file)
133
+ client = open_client(ctx, org_flag)
134
+ confirm(ctx, client, f"Upload {file.name} and admit it as the company's own material?")
135
+ result = upload_own_material(client, file)
136
+ if not result.get("success", True) and result.get("status") == "error":
137
+ raise CliError(ExitCode.UNAVAILABLE, "upload_failed", result.get("error_message") or "The upload failed.")
138
+ return result
139
+
140
+
141
+ def with_admission(client: RecalaClient, page: dict[str, Any]) -> dict[str, Any]:
142
+ """The added page plus its admission (state and the server's reason). The add stands if that read fails."""
143
+ if not page.get("document_id"):
144
+ return page
145
+ try:
146
+ document = client.call(KNOWLEDGE_PAGE, path={"doc_id": str(page["document_id"])}) or {}
147
+ except CliError:
148
+ return page
149
+ return {**page, "admission": document["admission"]} if document.get("admission") else page
150
+
151
+
152
+ def _describe_added_page(page: dict[str, Any], url: str | None = None) -> str:
153
+ name = page.get("title") or page.get("url") or url or "Page"
154
+ if page.get("success") is False:
155
+ return f"{name}: not added ({page.get('error') or 'the page could not be read'})"
156
+ passages = int(page.get("chunk_count") or 0)
157
+ line = f"{name}: added, {passages} passage{'' if passages == 1 else 's'}"
158
+ admission = page.get("admission") or {}
159
+ if admission.get("state") == "admitted":
160
+ return line + "; admitted"
161
+ return line + (f"; {str(admission['state']).replace('_', ' ')}: {admission.get('reason')}" if admission else "")
162
+
163
+
164
+ def _describe_added_pages(ctx: click.Context, result: dict[str, Any]) -> str:
165
+ if "results" in result:
166
+ return "\n".join(_describe_added_page(page) for page in result["results"]) or "No pages added."
167
+ if "document_id" not in result and "success" not in result:
168
+ return result.get("message") or "Added source page(s)."
169
+ return _describe_added_page(result, ctx.params["urls"][0])
170
+
171
+
172
+ @knowledge.command("add-url")
173
+ @common_options
174
+ @command_runner(contextual_human=_describe_added_pages)
175
+ @click.argument("urls", nargs=-1, required=True)
176
+ @accepted_yes_option
177
+ @dry_run_option
178
+ def add_urls(ctx: click.Context, org_flag: str | None, urls: tuple[str, ...]) -> dict[str, Any]:
179
+ """Add one or more pages as sources.
180
+
181
+ Says whether each was admitted, and why not."""
182
+ client = open_client(ctx, org_flag)
183
+ confirm(ctx, client, f"Add {len(urls)} page(s) as sources?")
184
+ if len(urls) == 1:
185
+ return with_admission(client, client.call(KNOWLEDGE_URL, json={"url": urls[0]}) or {})
186
+ many: dict[str, Any] = client.call(KNOWLEDGE_URLS, json={"urls": list(urls)}) or {}
187
+ return {**many, "results": [with_admission(client, page) for page in many.get("results") or []]}
188
+
189
+
190
+ @knowledge.command("remove")
191
+ @common_options
192
+ @command_runner(contextual_human=action_sentence("Removed"))
193
+ @ignored_yes_option
194
+ @click.argument("document_id", metavar="SOURCE_ID")
195
+ @dry_run_option
196
+ def remove_document(ctx: click.Context, org_flag: str | None, yes: bool, document_id: str) -> dict[str, Any]:
197
+ """Remove a source and everything derived from it."""
198
+ client = open_client(ctx, org_flag)
199
+ document_id = resolve_source_id(client, document_id)
200
+ label = resource_label(ctx, client, "source", document_id)
201
+ confirm(ctx, client, f"Remove {label}? This also erases what was learned from it.", yes=yes)
202
+ removed: dict[str, Any] = client.call(KNOWLEDGE_REMOVE, path={"doc_id": document_id})
203
+ return removed
204
+
205
+
206
+ def describe_admission(ctx: click.Context, result: dict[str, Any]) -> str:
207
+ kind = ADMIT_WORDS[ctx.params["kind"]]
208
+ if result.get("dry_run"):
209
+ return f"Dry run — would admit {ctx.meta['result_subject']} as {kind}."
210
+ return f"Admitted: {ctx.meta['result_subject']} as {kind}."
211
+
212
+
213
+ @knowledge.command("admit")
214
+ @common_options
215
+ @command_runner(contextual_human=describe_admission)
216
+ @ignored_yes_option
217
+ @click.argument("source_id", metavar="SOURCE_ID")
218
+ @click.option("--as", "kind", type=click.Choice(tuple(ADMIT_KINDS)), default="own", show_default=True,
219
+ help="What kind of material it is.")
220
+ @click.option("--dry-run", is_flag=True, help="Check the source can be admitted without admitting it.")
221
+ def admit_source(ctx: click.Context, org_flag: str | None, yes: bool, source_id: str, kind: str, dry_run: bool) -> dict[str, Any]:
222
+ """Admit a ready source for use in articles.
223
+
224
+ Needed for a source that is ready but not admitted. SOURCE_ID is the 8-character id from `recala knowledge list`."""
225
+ client = open_client(ctx, org_flag)
226
+ document_id = resolve_source_id(client, source_id)
227
+ label = resource_label(ctx, client, "source", document_id)
228
+ listed = (client.call(KNOWLEDGE_LIST) or {}).get("documents") or []
229
+ document = next((row for row in listed if str(row.get("id")) == document_id), None)
230
+ if document is None:
231
+ raise CliError(ExitCode.NOT_FOUND, "source_not_found",
232
+ "No source with that id in this company. Find it with: recala knowledge list")
233
+ if document.get("admission_state") == "admitted":
234
+ raise CliError(ExitCode.CONFLICT, "already_admitted", f"{label} is already admitted.")
235
+ if document.get("status") != "ready":
236
+ raise CliError(ExitCode.CONFLICT, "source_not_ready",
237
+ f"{label} is still {str(document.get('status') or 'being read').replace('_', ' ')}; "
238
+ "admit it once it is ready (recala knowledge list).")
239
+ if dry_run:
240
+ return {"dry_run": True, "document_id": document_id, "provenance_class": ADMIT_KINDS[kind]}
241
+ confirm(ctx, client, f"Admit {label} as {ADMIT_WORDS[kind]}?")
242
+ admitted: dict[str, Any] = client.call(
243
+ KNOWLEDGE_ADMIT, path={"doc_id": document_id},
244
+ json={"provenance_class": ADMIT_KINDS[kind], "publication_permission": "review_only"})
245
+ return admitted