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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
recala_cli/config.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Where the CLI keeps its settings and credentials, and the order it reads them in.
|
|
2
|
+
|
|
3
|
+
Files in one directory (RECALA_CONFIG_DIR, else $XDG_CONFIG_HOME/recala,
|
|
4
|
+
else ~/.config/recala), all written 0600:
|
|
5
|
+
|
|
6
|
+
* ``config.json``: the API URL, the web URL and the active organization. Not secret.
|
|
7
|
+
* ``credentials.json``: the stored session or key, never printed.
|
|
8
|
+
* ``grants.json``: the person's [a]ll answer for the current sign-in (see ``confirm``).
|
|
9
|
+
* ``onboarding.json``: which onboarding steps this machine finished, per organization.
|
|
10
|
+
|
|
11
|
+
Precedence, most specific first: a flag, then an environment variable, then the
|
|
12
|
+
file. Agents and CI set RECALA_API_KEY and RECALA_ORG and never touch the files.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
from recala_cli.errors import CliError, ExitCode
|
|
26
|
+
|
|
27
|
+
DEFAULT_API_URL = "https://recala-production.up.railway.app"
|
|
28
|
+
DEFAULT_WEB_URL = "https://recala.co"
|
|
29
|
+
_CONFIG = "config.json"
|
|
30
|
+
_CREDENTIALS = "credentials.json"
|
|
31
|
+
_GRANTS = "grants.json"
|
|
32
|
+
_ONBOARDING = "onboarding.json"
|
|
33
|
+
_SLUGS = "org-slugs.json"
|
|
34
|
+
_SLUG_TTL_SECONDS = 24 * 60 * 60
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_dir() -> Path:
|
|
38
|
+
explicit = os.environ.get("RECALA_CONFIG_DIR")
|
|
39
|
+
if explicit:
|
|
40
|
+
return Path(explicit)
|
|
41
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
42
|
+
return Path(base) / "recala"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def api_url(flag: str | None = None) -> str:
|
|
46
|
+
return (flag or os.environ.get("RECALA_API_URL") or _read(_CONFIG).get("api_url") or DEFAULT_API_URL).rstrip("/")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def web_url() -> str:
|
|
50
|
+
"""Where the person's browser goes (sign-in, Search Console, checkout): RECALA_WEB_URL, else what login learned."""
|
|
51
|
+
return (os.environ.get("RECALA_WEB_URL") or _read(_CONFIG).get("web_url") or DEFAULT_WEB_URL).rstrip("/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_web_url(url: str) -> None:
|
|
55
|
+
settings = _read(_CONFIG)
|
|
56
|
+
settings["web_url"] = url.rstrip("/")
|
|
57
|
+
_write(_CONFIG, settings)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def active_org(flag: str | None = None) -> str | None:
|
|
61
|
+
selector = flag if flag is not None else os.environ.get("RECALA_ORG")
|
|
62
|
+
if selector is not None:
|
|
63
|
+
if not selector.strip():
|
|
64
|
+
raise CliError(ExitCode.USAGE, "invalid_org", "--org needs a company name, slug or id.")
|
|
65
|
+
return selector
|
|
66
|
+
return _read(_CONFIG).get("org_id")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def saved_active_org() -> str | None:
|
|
70
|
+
"""The company saved by `recala company use`, ignoring --org and RECALA_ORG."""
|
|
71
|
+
return _read(_CONFIG).get("org_id")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_active_org(org_id: str) -> None:
|
|
75
|
+
settings = _read(_CONFIG)
|
|
76
|
+
settings["org_id"] = org_id
|
|
77
|
+
_write(_CONFIG, settings)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_org_id(value: str) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
UUID(value)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def environment_token() -> str | None:
|
|
89
|
+
return os.environ.get("RECALA_API_KEY") or os.environ.get("RECALA_TOKEN")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_credentials() -> dict[str, Any]:
|
|
93
|
+
return _read(_CREDENTIALS)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save_credentials(credentials: dict[str, Any]) -> None:
|
|
97
|
+
_write(_CREDENTIALS, credentials)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def forget_credentials() -> None:
|
|
101
|
+
(config_dir() / _CREDENTIALS).unlink(missing_ok=True)
|
|
102
|
+
forget_grant()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_grant() -> dict[str, Any]:
|
|
106
|
+
return _read(_GRANTS)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_grant(grant: dict[str, Any]) -> None:
|
|
110
|
+
_write(_GRANTS, grant)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def forget_grant() -> None:
|
|
114
|
+
(config_dir() / _GRANTS).unlink(missing_ok=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def onboarding_progress(org_id: str) -> dict[str, Any]:
|
|
118
|
+
entry = _read(_ONBOARDING).get(org_id)
|
|
119
|
+
return entry if isinstance(entry, dict) else {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_onboarding_progress(org_id: str, progress: dict[str, Any]) -> None:
|
|
123
|
+
everything = _read(_ONBOARDING)
|
|
124
|
+
everything[org_id] = progress
|
|
125
|
+
_write(_ONBOARDING, everything)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cached_org_slug(org_id: str) -> str | None:
|
|
129
|
+
"""A company's slug read in the last day: /me omits slugs, and reading each company costs a round trip."""
|
|
130
|
+
entry = _read_quietly(_SLUGS).get(org_id)
|
|
131
|
+
fresh = isinstance(entry, dict) and time.time() - float(entry.get("at") or 0) < _SLUG_TTL_SECONDS
|
|
132
|
+
return entry.get("slug") if fresh and isinstance(entry.get("slug"), str) else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def remember_org_slugs(identities: dict[str, dict[str, Any]]) -> None:
|
|
136
|
+
learned = {org_id: {"slug": found["slug"], "at": time.time()}
|
|
137
|
+
for org_id, found in identities.items() if isinstance(found.get("slug"), str)}
|
|
138
|
+
if not learned:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
_write(_SLUGS, {**_read_quietly(_SLUGS), **learned})
|
|
142
|
+
except OSError:
|
|
143
|
+
pass # a cache that cannot be written only costs the next command a read
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_quietly(name: str) -> dict[str, Any]:
|
|
147
|
+
try:
|
|
148
|
+
return _read(name)
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read(name: str) -> dict[str, Any]:
|
|
154
|
+
path = config_dir() / name
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return {}
|
|
157
|
+
data = json.loads(path.read_text())
|
|
158
|
+
return data if isinstance(data, dict) else {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _write(name: str, data: dict[str, Any]) -> None:
|
|
162
|
+
"""Atomic and owner-only: a crash never leaves half a file, and no other user can read it.
|
|
163
|
+
|
|
164
|
+
``mkstemp`` creates the file 0600 (a umask can only narrow that), and ``os.replace``
|
|
165
|
+
swaps it in whole, so even a credentials file some other tool left 0644 comes back 0600.
|
|
166
|
+
"""
|
|
167
|
+
directory = config_dir()
|
|
168
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
169
|
+
handle, temporary = tempfile.mkstemp(dir=directory, prefix=f".{name}.")
|
|
170
|
+
try:
|
|
171
|
+
with os.fdopen(handle, "w") as stream:
|
|
172
|
+
json.dump(data, stream)
|
|
173
|
+
os.replace(temporary, directory / name)
|
|
174
|
+
except BaseException:
|
|
175
|
+
Path(temporary).unlink(missing_ok=True)
|
|
176
|
+
raise
|
recala_cli/confirm.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Every write asks the person first: [y]es / [n]o / [a]ll (founder decision, 5 Oct 2026).
|
|
2
|
+
|
|
3
|
+
In a terminal the CLI asks before each write. ``a`` confirms the rest of this
|
|
4
|
+
command and records a grant for this sign-in (``grants.json``, bound to the
|
|
5
|
+
session's local id, 12 hours at most, gone at logout or the next login).
|
|
6
|
+
|
|
7
|
+
Without a terminal (an agent, a pipe, --json) nothing ever prompts. A write then
|
|
8
|
+
goes ahead only with:
|
|
9
|
+
|
|
10
|
+
* a prior [a]ll grant for this sign-in (``recala grant``, or an ``a`` answered
|
|
11
|
+
earlier), or
|
|
12
|
+
* an API key: its scopes are its authorization, checked by the server (spending
|
|
13
|
+
also needs the cost preview the server demands). An action that publishes
|
|
14
|
+
(approve, schedule, unschedule, unpublish, or a key that may do those) also
|
|
15
|
+
needs --yes, and --yes is accepted only from a key that holds ``publish``.
|
|
16
|
+
|
|
17
|
+
Otherwise nothing is sent and the command exits 10. This module only decides
|
|
18
|
+
whether to send; every request still meets the server's own gates.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import click
|
|
29
|
+
|
|
30
|
+
from recala_cli import auth, config
|
|
31
|
+
from recala_cli.client import RecalaClient
|
|
32
|
+
from recala_cli.endpoints import ME
|
|
33
|
+
from recala_cli.errors import CliError, ExitCode
|
|
34
|
+
from recala_cli.output import notify
|
|
35
|
+
|
|
36
|
+
GRANT_HOURS = 12
|
|
37
|
+
PUBLISH_SCOPE = "publish"
|
|
38
|
+
_ANSWERS = {"y": "yes", "yes": "yes", "n": "no", "no": "no", "": "no", "a": "all", "all": "all"}
|
|
39
|
+
_ATTEMPTS = 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_interactive(ctx: click.Context) -> bool:
|
|
43
|
+
"""A person at a terminal: never under --json, and only when both stdin and stderr are a TTY."""
|
|
44
|
+
seams = ctx.find_root().obj or {}
|
|
45
|
+
if seams.get("as_json"):
|
|
46
|
+
return False
|
|
47
|
+
forced = seams.get("interactive")
|
|
48
|
+
if forced is not None:
|
|
49
|
+
return bool(forced)
|
|
50
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def grant_status() -> dict[str, Any]:
|
|
54
|
+
grant, session = config.load_grant(), auth.session_id()
|
|
55
|
+
active = bool(session) and grant.get("session_id") == session and time.time() < float(grant.get("expires_at") or 0)
|
|
56
|
+
return {"active": active, "expires_at": grant.get("expires_at") if active else None}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def require_current_signin() -> None:
|
|
60
|
+
if (not config.environment_token() and auth.credential_kind() in {"session", "access_token"}
|
|
61
|
+
and not auth.session_id()):
|
|
62
|
+
raise CliError(ExitCode.NOT_AUTHENTICATED, "legacy_signin",
|
|
63
|
+
"This sign-in predates saved confirmations. Run `recala login` again, then `recala grant`.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def record_grant() -> dict[str, Any]:
|
|
67
|
+
require_current_signin()
|
|
68
|
+
session = auth.session_id()
|
|
69
|
+
if not session:
|
|
70
|
+
raise CliError(ExitCode.CONFLICT, "no_session",
|
|
71
|
+
"Only a person's sign-in (`recala login`) can hold an [a]ll grant; an API key uses its scopes.")
|
|
72
|
+
now = time.time()
|
|
73
|
+
config.save_grant({"session_id": session, "granted_at": now, "expires_at": now + GRANT_HOURS * 3600})
|
|
74
|
+
return grant_status()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def confirm_write(ctx: click.Context, client: RecalaClient | None, summary: str, *, publishes: bool = False,
|
|
78
|
+
yes: bool = False) -> None:
|
|
79
|
+
"""Return when the person (or the key) confirmed this write; raise exit 10 when nothing may be sent."""
|
|
80
|
+
require_current_signin()
|
|
81
|
+
seams = ctx.find_root().obj
|
|
82
|
+
if seams.get("all_confirmed"):
|
|
83
|
+
return
|
|
84
|
+
with_key = auth.credential_kind() == "api_key"
|
|
85
|
+
if with_key and publishes and yes:
|
|
86
|
+
_require_publish_scope(seams, client)
|
|
87
|
+
return
|
|
88
|
+
grant = grant_status() if not with_key else {}
|
|
89
|
+
if grant.get("active"):
|
|
90
|
+
seams["all_confirmed"] = True
|
|
91
|
+
if not seams.get("as_json"):
|
|
92
|
+
until = datetime.fromtimestamp(float(grant["expires_at"])).strftime("%a %d %b, %H:%M")
|
|
93
|
+
notify(f"Confirmed by your agent grant (until {until}).")
|
|
94
|
+
return
|
|
95
|
+
if is_interactive(ctx):
|
|
96
|
+
_ask(seams, summary, record=not with_key)
|
|
97
|
+
return
|
|
98
|
+
if with_key and not publishes:
|
|
99
|
+
return
|
|
100
|
+
raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required", _missing(summary, with_key=with_key))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _missing(summary: str, *, with_key: bool) -> str:
|
|
104
|
+
if with_key:
|
|
105
|
+
return f"{summary} Nothing was sent: re-run with --yes, using an API key that has the publish scope."
|
|
106
|
+
return (f"{summary} Nothing was sent: this needs your confirmation. Run it in a terminal and answer, or run "
|
|
107
|
+
"`recala grant` once so agents can write with this sign-in. (--yes works only with an API key that "
|
|
108
|
+
"has the publish scope.)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _require_publish_scope(seams: dict[str, Any], client: RecalaClient | None) -> None:
|
|
112
|
+
scopes = seams.get("key_scopes")
|
|
113
|
+
if scopes is None and client is not None:
|
|
114
|
+
me = client.call(ME) or {}
|
|
115
|
+
scopes = set((me.get("credential") or {}).get("scopes") or [])
|
|
116
|
+
seams["key_scopes"] = scopes
|
|
117
|
+
if PUBLISH_SCOPE not in (scopes or set()):
|
|
118
|
+
raise CliError(ExitCode.CONFIRMATION_REQUIRED, "yes_needs_publish_scope",
|
|
119
|
+
"--yes is accepted only from an API key with the publish scope, and this key does not hold "
|
|
120
|
+
"it. Nothing was sent.")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ask(seams: dict[str, Any], summary: str, *, record: bool) -> None:
|
|
124
|
+
answer = "no"
|
|
125
|
+
for _ in range(_ATTEMPTS):
|
|
126
|
+
raw = click.prompt(f" {summary} [y]es / [n]o / [a]ll", default="", show_default=False, err=True)
|
|
127
|
+
if raw.strip().lower() in _ANSWERS:
|
|
128
|
+
answer = _ANSWERS[raw.strip().lower()]
|
|
129
|
+
break
|
|
130
|
+
if answer == "no":
|
|
131
|
+
raise CliError(ExitCode.CONFIRMATION_REQUIRED, "declined", "Not done: you answered no. Nothing was sent.")
|
|
132
|
+
if answer == "all":
|
|
133
|
+
seams["all_confirmed"] = True
|
|
134
|
+
if record and auth.session_id():
|
|
135
|
+
record_grant()
|
|
136
|
+
notify(f" Confirmed: every write for this sign-in (until logout or {GRANT_HOURS} hours). "
|
|
137
|
+
"`recala grant --revoke` undoes it.")
|
recala_cli/display.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Untrusted terminal text, sanitized before Click can render it."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_ESCAPES = re.compile(
|
|
8
|
+
r"(?:\x1b\]|\x9d).*?(?:\x07|\x1b\\|\x9c|$)"
|
|
9
|
+
r"|(?:\x1b[P^_X]|[\x90\x98\x9e\x9f]).*?(?:\x1b\\|\x9c|$)"
|
|
10
|
+
r"|(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]"
|
|
11
|
+
r"|\x1b[ -/]*[@-~]",
|
|
12
|
+
re.DOTALL,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def terminal_text(value: str, *, keep_layout: bool = False) -> str:
|
|
17
|
+
"""Remove terminal commands and Unicode controls, including bidi overrides."""
|
|
18
|
+
text = _ESCAPES.sub("", value)
|
|
19
|
+
return "".join(
|
|
20
|
+
ch if not unicodedata.category(ch).startswith("C") or (keep_layout and ch in "\n\t")
|
|
21
|
+
else " " if ch.isspace() else ""
|
|
22
|
+
for ch in text
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def display_name(value: Any) -> str:
|
|
27
|
+
return " ".join(terminal_text(value).split()) if isinstance(value, str) else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def human_data(value: Any, *, field: str = "") -> Any:
|
|
31
|
+
"""A sanitized presentation copy; the API data still goes unchanged to JSON."""
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
if field in ("name", "display_name", "article_title", "title", "topic", "filename", "company", "author_name"):
|
|
34
|
+
return display_name(value)
|
|
35
|
+
return terminal_text(value, keep_layout=field in ("markdown", "markdown_content"))
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
return {terminal_text(str(key)): human_data(item, field=str(key)) for key, item in value.items()}
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [human_data(item, field=field) for item in value]
|
|
40
|
+
return value
|
recala_cli/endpoints.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Every API route the CLI calls, in one table.
|
|
2
|
+
|
|
3
|
+
apps/api/tests/test_cli_calls_only_routes_the_api_serves.py reads this table
|
|
4
|
+
against the live FastAPI app: a live endpoint must be served with its method, and
|
|
5
|
+
a ``planned`` one must not exist yet. Commands never spell a path inline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Endpoint:
|
|
16
|
+
method: str
|
|
17
|
+
path: str
|
|
18
|
+
planned: bool = False # designed (DESIGN.md) but not in the API yet
|
|
19
|
+
changes_nothing: bool = False # a POST that only prices, signs in or polls: no answer means "try again"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def writes(self) -> bool:
|
|
23
|
+
return self.method != "GET" and not self.changes_nothing
|
|
24
|
+
|
|
25
|
+
def url_path(self, **params: str) -> str:
|
|
26
|
+
return self.path.format(**{name: quote(value, safe="") for name, value in params.items()})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ORG_SHOW = Endpoint("GET", "/api/v1/orgs/{org_id}")
|
|
30
|
+
BILLING_CREDIT_SUMMARY = Endpoint("GET", "/api/v1/billing/credits/summary")
|
|
31
|
+
BILLING_CREDITS = Endpoint("GET", "/api/v1/billing/credits")
|
|
32
|
+
BILLING_SUBSCRIPTION = Endpoint("GET", "/api/v1/billing/subscription")
|
|
33
|
+
BILLING_MANAGED_USAGE = Endpoint("GET", "/api/v1/billing/managed-usage")
|
|
34
|
+
BILLING_PAY_LINK = Endpoint("POST", "/api/v1/billing/subscription/pay-link")
|
|
35
|
+
TOPICS_LIST = Endpoint("GET", "/api/v1/content-queue")
|
|
36
|
+
|
|
37
|
+
ME = Endpoint("GET", "/api/v1/me")
|
|
38
|
+
DEVICE_START = Endpoint("POST", "/api/v1/cli/device", changes_nothing=True)
|
|
39
|
+
DEVICE_TOKEN = Endpoint("POST", "/api/v1/cli/device/token", changes_nothing=True)
|
|
40
|
+
TOKEN_REFRESH = Endpoint("POST", "/api/v1/cli/token/refresh", changes_nothing=True)
|
|
41
|
+
SIGN_OUT = Endpoint("POST", "/api/v1/cli/logout")
|
|
42
|
+
KEYS_LIST = Endpoint("GET", "/api/v1/keys")
|
|
43
|
+
KEYS_CREATE = Endpoint("POST", "/api/v1/keys")
|
|
44
|
+
KEY_REVOKE = Endpoint("DELETE", "/api/v1/keys/{key_id}")
|
|
45
|
+
KEY_EVENTS = Endpoint("GET", "/api/v1/keys/{key_id}/events")
|
|
46
|
+
|
|
47
|
+
# Slice 3: content verbs.
|
|
48
|
+
ARTICLES_LIST = Endpoint("GET", "/api/v1/articles")
|
|
49
|
+
ARTICLE_SHOW = Endpoint("GET", "/api/v1/articles/{article_id}")
|
|
50
|
+
ARTICLE_VERSIONS = Endpoint("GET", "/api/v1/articles/{article_id}/versions")
|
|
51
|
+
ARTICLE_VERSION = Endpoint("GET", "/api/v1/articles/{article_id}/versions/{number}")
|
|
52
|
+
ARTICLE_BODY = Endpoint("PATCH", "/api/v1/articles/{article_id}/body")
|
|
53
|
+
ARTICLE_REDRAFT_START = Endpoint("POST", "/api/v1/articles/{article_id}/redraft")
|
|
54
|
+
ARTICLE_REDRAFT_LATEST = Endpoint("GET", "/api/v1/articles/{article_id}/redraft")
|
|
55
|
+
ARTICLE_REDRAFT_APPLY = Endpoint("POST", "/api/v1/articles/{article_id}/redraft/{redraft_id}/apply")
|
|
56
|
+
ARTICLE_PERSON = Endpoint("POST", "/api/v1/articles/{article_id}/person")
|
|
57
|
+
ARTICLE_SUBMIT = Endpoint("POST", "/api/v1/articles/{article_id}/submit-review")
|
|
58
|
+
ARTICLE_REQUEST_CHANGES = Endpoint("POST", "/api/v1/articles/{article_id}/request-changes")
|
|
59
|
+
ARTICLE_COMMENTS = Endpoint("GET", "/api/v1/articles/{article_id}/review-comments")
|
|
60
|
+
REVIEW_LINK_SHOW = Endpoint("GET", "/api/v1/articles/{article_id}/review-link")
|
|
61
|
+
REVIEW_LINK_CREATE = Endpoint("POST", "/api/v1/articles/{article_id}/review-link")
|
|
62
|
+
REVIEW_LINK_REVOKE = Endpoint("DELETE", "/api/v1/articles/{article_id}/review-link")
|
|
63
|
+
SHARE_TOKEN_ROTATE = Endpoint("POST", "/api/v1/articles/{article_id}/rotate-share-token")
|
|
64
|
+
TOPIC_ADD = Endpoint("POST", "/api/v1/content-queue")
|
|
65
|
+
TOPIC_DISMISS = Endpoint("POST", "/api/v1/content-queue/{queue_id}/dismiss")
|
|
66
|
+
TOPIC_APPROVE = Endpoint("POST", "/api/v1/content-queue/{queue_id}/approve")
|
|
67
|
+
TOPIC_WHY = Endpoint("GET", "/api/v1/content-queue/{item_id}/rationale")
|
|
68
|
+
KNOWLEDGE_LIST = Endpoint("GET", "/api/v1/knowledge/documents")
|
|
69
|
+
KNOWLEDGE_UPLOAD = Endpoint("POST", "/api/v1/knowledge/upload")
|
|
70
|
+
KNOWLEDGE_URL = Endpoint("POST", "/api/v1/knowledge/extract-url")
|
|
71
|
+
KNOWLEDGE_URLS = Endpoint("POST", "/api/v1/knowledge/extract-urls")
|
|
72
|
+
KNOWLEDGE_REMOVE = Endpoint("DELETE", "/api/v1/knowledge/documents/{doc_id}")
|
|
73
|
+
KNOWLEDGE_PAGE = Endpoint("GET", "/api/v1/knowledge/documents/{doc_id}/page")
|
|
74
|
+
PEOPLE_LIST = Endpoint("GET", "/api/v1/people")
|
|
75
|
+
CALENDAR = Endpoint("GET", "/api/v1/publication-calendar")
|
|
76
|
+
AUTHORITY_SCORE = Endpoint("GET", "/api/v1/authority-score")
|
|
77
|
+
|
|
78
|
+
# Slice 4: approving, publishing, spending, connecting.
|
|
79
|
+
ARTICLE_APPROVE = Endpoint("POST", "/api/v1/articles/{article_id}/approve")
|
|
80
|
+
ARTICLE_UNPUBLISH = Endpoint("POST", "/api/v1/articles/{article_id}/unpublish")
|
|
81
|
+
ARTICLE_REPUBLISH = Endpoint("POST", "/api/v1/articles/{article_id}/republish")
|
|
82
|
+
ARTICLE_READINESS = Endpoint("GET", "/api/v1/articles/{article_id}/publication-readiness")
|
|
83
|
+
APPOINTMENT_BOOK = Endpoint("PUT", "/api/v1/articles/{article_id}/publication-appointment")
|
|
84
|
+
APPOINTMENT_CANCEL = Endpoint("DELETE", "/api/v1/articles/{article_id}/publication-appointment")
|
|
85
|
+
COST_PREVIEW = Endpoint("POST", "/api/v1/cost-previews", changes_nothing=True)
|
|
86
|
+
GENERATE_FROM_TOPIC = Endpoint("POST", "/api/v1/generate/from-queue/{queue_id}")
|
|
87
|
+
BILLING_SPEND = Endpoint("GET", "/api/v1/billing/spend")
|
|
88
|
+
CMS_STATUS = Endpoint("GET", "/api/v1/cms/status")
|
|
89
|
+
GSC_STATUS = Endpoint("GET", "/api/v1/gsc/status")
|
|
90
|
+
GSC_SERVICE_ACCOUNT = Endpoint("GET", "/api/v1/gsc/service-account")
|
|
91
|
+
GSC_SERVICE_ACCOUNT_CHECK = Endpoint("POST", "/api/v1/gsc/service-account/check")
|
|
92
|
+
GSC_PERFORMANCE = Endpoint("GET", "/api/v1/gsc/performance")
|
|
93
|
+
BING_STATUS = Endpoint("GET", "/api/v1/bing/status")
|
|
94
|
+
BING_SITES = Endpoint("GET", "/api/v1/bing/sites")
|
|
95
|
+
BING_SITE = Endpoint("PUT", "/api/v1/bing/site")
|
|
96
|
+
BING_PERFORMANCE = Endpoint("GET", "/api/v1/bing/performance")
|
|
97
|
+
|
|
98
|
+
# Onboarding from the terminal: the routes the portal's wizard, Settings and billing pages call.
|
|
99
|
+
ORG_CREATE = Endpoint("POST", "/api/v1/orgs")
|
|
100
|
+
ORG_UPDATE = Endpoint("PATCH", "/api/v1/orgs/{org_id}")
|
|
101
|
+
ONBOARDING_SCRAPE = Endpoint("POST", "/api/v1/onboarding/scrape")
|
|
102
|
+
ONBOARDING_SAVE = Endpoint("POST", "/api/v1/onboarding/save-entities")
|
|
103
|
+
ONBOARDING_COMPLETE = Endpoint("POST", "/api/v1/onboarding/complete")
|
|
104
|
+
PERSON_CREATE = Endpoint("POST", "/api/v1/people")
|
|
105
|
+
PERSON_UPDATE = Endpoint("PATCH", "/api/v1/people/{person_id}")
|
|
106
|
+
KNOWLEDGE_ADMIT = Endpoint("POST", "/api/v1/knowledge/documents/{doc_id}/admit")
|
|
107
|
+
SANITY_STATUS = Endpoint("GET", "/api/v1/cms/sanity/status")
|
|
108
|
+
SANITY_CONNECT = Endpoint("POST", "/api/v1/cms/sanity/connect")
|
|
109
|
+
WORDPRESS_VERIFY = Endpoint("POST", "/api/v1/wordpress/verify", changes_nothing=True)
|
|
110
|
+
WORDPRESS_CONNECT = Endpoint("POST", "/api/v1/wordpress/connect")
|
|
111
|
+
GSC_PROPERTIES = Endpoint("GET", "/api/v1/gsc/properties")
|
|
112
|
+
GSC_PROPERTY = Endpoint("PUT", "/api/v1/gsc/property")
|
|
113
|
+
CREDITS_SUMMARY = Endpoint("GET", "/api/v1/billing/credits/summary")
|
|
114
|
+
ENTITLEMENTS = Endpoint("GET", "/api/v1/billing/entitlements")
|
|
115
|
+
QUESTION_PANEL = Endpoint("GET", "/api/v1/question-panel")
|
|
116
|
+
QUESTION_PANEL_BASELINE = Endpoint("POST", "/api/v1/question-panel/baseline")
|
|
117
|
+
|
|
118
|
+
# Indexing routes are served by main; feature flags can still return 404 at runtime.
|
|
119
|
+
INDEXING_SITEMAPS = Endpoint("GET", "/api/v1/indexing/sitemaps")
|
|
120
|
+
INDEXING_SITEMAPS_SUBMIT = Endpoint("POST", "/api/v1/indexing/sitemaps/submit")
|
|
121
|
+
ARTICLE_INDEXING = Endpoint("GET", "/api/v1/articles/{article_id}/indexing")
|
|
122
|
+
ARTICLE_INDEXING_SUBMIT = Endpoint("POST", "/api/v1/articles/{article_id}/indexing/submit")
|
|
123
|
+
|
|
124
|
+
TOPICS_SCOUT = Endpoint("POST", "/api/v1/topics/scout")
|
|
125
|
+
TOPICS_SCOUT_STATUS = Endpoint("GET", "/api/v1/topics/scout/status")
|
|
126
|
+
|
|
127
|
+
CADENCE_SHOW = Endpoint("GET", "/api/v1/publication-cadence")
|
|
128
|
+
CADENCE_SET = Endpoint("PUT", "/api/v1/publication-cadence")
|
|
129
|
+
AUTOAPPROVE_SHOW = Endpoint("GET", "/api/v1/orgs/current/auto-approve")
|
|
130
|
+
AUTOAPPROVE_SET = Endpoint("PUT", "/api/v1/orgs/current/auto-approve")
|
|
131
|
+
FAITHFUL_TRANSLATIONS_SHOW = Endpoint(
|
|
132
|
+
"GET", "/api/v1/orgs/current/auto-approve/faithful-translations"
|
|
133
|
+
)
|
|
134
|
+
FAITHFUL_TRANSLATIONS_SET = Endpoint(
|
|
135
|
+
"PUT", "/api/v1/orgs/current/auto-approve/faithful-translations"
|
|
136
|
+
)
|
|
137
|
+
PUBLICATION_PLAN = Endpoint("GET", "/api/v1/publication-plan")
|
|
138
|
+
ESCALATED_SLOTS = Endpoint("GET", "/api/v1/publication-slots/escalated")
|
|
139
|
+
SLOT_TOPIC_REPLACE = Endpoint("POST", "/api/v1/publication-slots/{slot_id}/topic")
|
|
140
|
+
|
|
141
|
+
GENERATION_STATUS = Endpoint("GET", "/api/v1/runs/{queue_id}")
|
|
142
|
+
ARTICLE_REGENERATE = Endpoint("POST", "/api/v1/articles/{article_id}/regenerate")
|
|
143
|
+
ARTICLE_RECHECK = Endpoint("POST", "/api/v1/articles/{article_id}/recheck")
|
|
144
|
+
UP_NEXT_ADD = Endpoint("POST", "/api/v1/up-next/add")
|
|
145
|
+
UP_NEXT_REMOVE = Endpoint("POST", "/api/v1/up-next/remove")
|
|
146
|
+
UP_NEXT_REORDER = Endpoint("POST", "/api/v1/up-next/reorder")
|
|
147
|
+
|
|
148
|
+
# 0.6.0: what the portal does that the terminal could not.
|
|
149
|
+
LANGUAGES_SHOW = Endpoint("GET", "/api/v1/org-settings/languages")
|
|
150
|
+
LANGUAGES_SET = Endpoint("PATCH", "/api/v1/org-settings/languages")
|
|
151
|
+
NOTIFICATIONS_LIST = Endpoint("GET", "/api/v1/notifications")
|
|
152
|
+
NOTIFICATION_READ = Endpoint("POST", "/api/v1/notifications/{notification_id}/read")
|
|
153
|
+
NOTIFICATIONS_READ_ALL = Endpoint("POST", "/api/v1/notifications/read-all")
|
|
154
|
+
REPORT_MONTHLY = Endpoint("GET", "/api/v1/reports/monthly")
|
|
155
|
+
REPORT_SHARES = Endpoint("GET", "/api/v1/reports/monthly/shares")
|
|
156
|
+
REPORT_SHARE_CREATE = Endpoint("POST", "/api/v1/reports/monthly/share")
|
|
157
|
+
REFRESH_SUMMARY = Endpoint("GET", "/api/v1/billing/refresh-summary")
|
|
158
|
+
ARTICLE_AUTO_REFRESH = Endpoint("PATCH", "/api/v1/articles/{article_id}/auto-refresh")
|
|
159
|
+
REFRESH_STATUS = Endpoint("GET", "/api/v1/refreshes/{refresh_id}/status")
|
|
160
|
+
PROFILE_AUTHOR = Endpoint("GET", "/api/v1/author")
|
|
161
|
+
PROFILE_POSITIONING = Endpoint("GET", "/api/v1/org-profile/positioning")
|
|
162
|
+
PROFILE_INTENT = Endpoint("GET", "/api/v1/intent")
|
|
163
|
+
BILLING_INVOICES = Endpoint("GET", "/api/v1/billing/invoices")
|
|
164
|
+
|
|
165
|
+
ALL_ENDPOINTS = (
|
|
166
|
+
LANGUAGES_SHOW, LANGUAGES_SET, NOTIFICATIONS_LIST, NOTIFICATION_READ, NOTIFICATIONS_READ_ALL,
|
|
167
|
+
REPORT_MONTHLY, REPORT_SHARES, REPORT_SHARE_CREATE, REFRESH_SUMMARY, ARTICLE_AUTO_REFRESH, REFRESH_STATUS,
|
|
168
|
+
PROFILE_AUTHOR, PROFILE_POSITIONING, PROFILE_INTENT, BILLING_INVOICES,
|
|
169
|
+
GENERATION_STATUS, ARTICLE_REGENERATE, ARTICLE_RECHECK,
|
|
170
|
+
UP_NEXT_ADD, UP_NEXT_REMOVE, UP_NEXT_REORDER,
|
|
171
|
+
TOPICS_SCOUT,
|
|
172
|
+
TOPICS_SCOUT_STATUS,
|
|
173
|
+
CADENCE_SHOW, CADENCE_SET, AUTOAPPROVE_SHOW, AUTOAPPROVE_SET, PUBLICATION_PLAN,
|
|
174
|
+
ESCALATED_SLOTS, SLOT_TOPIC_REPLACE,
|
|
175
|
+
FAITHFUL_TRANSLATIONS_SHOW, FAITHFUL_TRANSLATIONS_SET,
|
|
176
|
+
ORG_SHOW,
|
|
177
|
+
BILLING_CREDITS,
|
|
178
|
+
BILLING_CREDIT_SUMMARY,
|
|
179
|
+
BILLING_SUBSCRIPTION,
|
|
180
|
+
BILLING_MANAGED_USAGE,
|
|
181
|
+
TOPICS_LIST,
|
|
182
|
+
ME,
|
|
183
|
+
DEVICE_START,
|
|
184
|
+
DEVICE_TOKEN,
|
|
185
|
+
TOKEN_REFRESH,
|
|
186
|
+
SIGN_OUT,
|
|
187
|
+
KEYS_LIST,
|
|
188
|
+
KEYS_CREATE,
|
|
189
|
+
KEY_REVOKE,
|
|
190
|
+
KEY_EVENTS,
|
|
191
|
+
ARTICLES_LIST,
|
|
192
|
+
ARTICLE_SHOW,
|
|
193
|
+
ARTICLE_VERSIONS,
|
|
194
|
+
ARTICLE_VERSION,
|
|
195
|
+
ARTICLE_BODY,
|
|
196
|
+
ARTICLE_REDRAFT_START,
|
|
197
|
+
ARTICLE_REDRAFT_LATEST,
|
|
198
|
+
ARTICLE_REDRAFT_APPLY,
|
|
199
|
+
ARTICLE_PERSON,
|
|
200
|
+
ARTICLE_SUBMIT,
|
|
201
|
+
ARTICLE_REQUEST_CHANGES,
|
|
202
|
+
ARTICLE_COMMENTS,
|
|
203
|
+
REVIEW_LINK_SHOW,
|
|
204
|
+
REVIEW_LINK_CREATE,
|
|
205
|
+
REVIEW_LINK_REVOKE,
|
|
206
|
+
SHARE_TOKEN_ROTATE,
|
|
207
|
+
TOPIC_ADD,
|
|
208
|
+
TOPIC_DISMISS,
|
|
209
|
+
TOPIC_APPROVE,
|
|
210
|
+
TOPIC_WHY,
|
|
211
|
+
KNOWLEDGE_LIST,
|
|
212
|
+
KNOWLEDGE_UPLOAD,
|
|
213
|
+
KNOWLEDGE_URL,
|
|
214
|
+
KNOWLEDGE_URLS,
|
|
215
|
+
KNOWLEDGE_REMOVE,
|
|
216
|
+
KNOWLEDGE_PAGE,
|
|
217
|
+
PEOPLE_LIST,
|
|
218
|
+
CALENDAR,
|
|
219
|
+
AUTHORITY_SCORE,
|
|
220
|
+
ARTICLE_APPROVE,
|
|
221
|
+
ARTICLE_UNPUBLISH,
|
|
222
|
+
ARTICLE_REPUBLISH,
|
|
223
|
+
ARTICLE_READINESS,
|
|
224
|
+
APPOINTMENT_BOOK,
|
|
225
|
+
APPOINTMENT_CANCEL,
|
|
226
|
+
COST_PREVIEW,
|
|
227
|
+
GENERATE_FROM_TOPIC,
|
|
228
|
+
BILLING_SPEND,
|
|
229
|
+
CMS_STATUS,
|
|
230
|
+
GSC_STATUS,
|
|
231
|
+
GSC_SERVICE_ACCOUNT,
|
|
232
|
+
GSC_SERVICE_ACCOUNT_CHECK,
|
|
233
|
+
GSC_PERFORMANCE,
|
|
234
|
+
BING_STATUS,
|
|
235
|
+
BING_PERFORMANCE,
|
|
236
|
+
ORG_CREATE,
|
|
237
|
+
ORG_UPDATE,
|
|
238
|
+
ONBOARDING_SCRAPE,
|
|
239
|
+
ONBOARDING_SAVE,
|
|
240
|
+
ONBOARDING_COMPLETE,
|
|
241
|
+
PERSON_CREATE,
|
|
242
|
+
PERSON_UPDATE,
|
|
243
|
+
KNOWLEDGE_ADMIT,
|
|
244
|
+
SANITY_STATUS,
|
|
245
|
+
SANITY_CONNECT,
|
|
246
|
+
WORDPRESS_VERIFY,
|
|
247
|
+
WORDPRESS_CONNECT,
|
|
248
|
+
GSC_PROPERTIES,
|
|
249
|
+
GSC_PROPERTY,
|
|
250
|
+
CREDITS_SUMMARY,
|
|
251
|
+
ENTITLEMENTS,
|
|
252
|
+
QUESTION_PANEL,
|
|
253
|
+
QUESTION_PANEL_BASELINE,
|
|
254
|
+
INDEXING_SITEMAPS,
|
|
255
|
+
INDEXING_SITEMAPS_SUBMIT,
|
|
256
|
+
ARTICLE_INDEXING,
|
|
257
|
+
ARTICLE_INDEXING_SUBMIT,
|
|
258
|
+
)
|