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/ids.py ADDED
@@ -0,0 +1,139 @@
1
+ """Resolve displayed IDs without guessing, before any resource request or write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import wraps
7
+
8
+ from recala_cli.display import display_name
9
+ from recala_cli.endpoints import ARTICLES_LIST, KNOWLEDGE_LIST, PEOPLE_LIST, TOPICS_LIST
10
+ from recala_cli.errors import CliError, ExitCode
11
+
12
+ TOPIC_SECTIONS = ("up_next", "backlog", "queued", "processing", "other")
13
+ _PREFIX = re.compile(r"[0-9a-f]{8}", re.IGNORECASE)
14
+ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", re.IGNORECASE)
15
+ _ARTICLE_PAGE_LIMIT = 10
16
+
17
+
18
+ def is_short_id(selector: str) -> bool:
19
+ return bool(_PREFIX.fullmatch(selector))
20
+
21
+
22
+ def validate_id(selector: str, kind: str) -> None:
23
+ if not (_UUID.fullmatch(selector) or is_short_id(selector)):
24
+ command = "article" if kind == "article" else "topics"
25
+ article = "an" if kind == "article" else "a"
26
+ raise CliError(ExitCode.USAGE, f"invalid_{kind}_id",
27
+ f"{selector} is not {article} {kind} id. Use the 8-character id or the full id from recala {command} list")
28
+
29
+
30
+ def id_arguments(**kinds):
31
+ """Validate before opening a client, which can itself make organization/auth reads."""
32
+ def decorate(command):
33
+ @wraps(command)
34
+ def validated(*args, **kwargs):
35
+ for argument, kind in kinds.items():
36
+ value = kwargs[argument]
37
+ for selector in value if isinstance(value, tuple) else (value,):
38
+ validate_id(selector, kind)
39
+ return command(*args, **kwargs)
40
+ return validated
41
+ return decorate
42
+
43
+
44
+ def matching_prefix(rows, selector, *, key="id"):
45
+ return [row for row in rows if str(row[key]).lower().startswith(selector.lower())]
46
+
47
+
48
+ def resolve_article_id(client, selector: str) -> str:
49
+ validate_id(selector, "article")
50
+ if not is_short_id(selector):
51
+ return selector
52
+ matches = {}
53
+ for page_number in range(_ARTICLE_PAGE_LIMIT):
54
+ offset = page_number * 100
55
+ page = client.call(ARTICLES_LIST, params={"limit": 100, "offset": offset})
56
+ rows = page.get("articles") or []
57
+ matches.update((row["id"], row) for row in matching_prefix(rows, selector))
58
+ total = page.get("total")
59
+ exhausted = offset + 100 >= total if isinstance(total, int) else len(rows) < 100
60
+ if exhausted:
61
+ break
62
+ if not exhausted and len(matches) < 2:
63
+ raise CliError(ExitCode.USAGE, "article_lookup_incomplete",
64
+ "Stopped after 10 pages of articles; a unique match cannot be confirmed. "
65
+ "Use the full id from recala article list --json (page with --offset).")
66
+ if len(matches) > 1:
67
+ choices = "\n".join(f"{display_name(row.get('article_title') or row.get('topic') or 'Untitled')} [{row['id']}]"
68
+ for row in matches.values())
69
+ cap_notice = "\nStopped after 10 pages; there may be more matches." if not exhausted else ""
70
+ raise CliError(ExitCode.USAGE, "article_ambiguous", f"Several articles start with {selector}:\n{choices}{cap_notice}")
71
+ if not matches:
72
+ raise CliError(ExitCode.USAGE, "article_not_found",
73
+ f"No article starts with {selector}. Find its id with: recala article list")
74
+ return next(iter(matches))
75
+
76
+
77
+ def read_topics(client, *, limit=None, offset=0):
78
+ """Page the base query, preserving Up next order and deduplicating pinned rows."""
79
+ page_size = limit or 100
80
+ result = {name: [] for name in TOPIC_SECTIONS}
81
+ seen = {name: set() for name in TOPIC_SECTIONS}
82
+ while True:
83
+ page = client.call(TOPICS_LIST, params={"limit": page_size, "offset": offset})
84
+ for section in TOPIC_SECTIONS:
85
+ for row in page.get(section) or []:
86
+ if row.get("id") not in seen[section]:
87
+ result[section].append(row)
88
+ seen[section].add(row.get("id"))
89
+ result["total"] = page.get("total") or 0
90
+ offset += page_size
91
+ if limit or offset >= result["total"] or page.get("gated_count"):
92
+ return result
93
+
94
+
95
+ def resolve_topic(data, selector):
96
+ """Up next accepts exact titles too; ID-only arguments validate separately."""
97
+ rows = {row["id"]: row for name in TOPIC_SECTIONS for row in data.get(name) or []}
98
+ if selector in rows:
99
+ return rows[selector]
100
+ matches = (matching_prefix(rows.values(), selector) if is_short_id(selector) else
101
+ [row for row in rows.values() if str(row.get("topic") or "").casefold() == selector.casefold()])
102
+ if len(matches) != 1:
103
+ choices = "\n".join(f"{display_name(row['topic'])} [{row['id']}]" for row in matches)
104
+ raise CliError(ExitCode.USAGE, "topic_ambiguous" if matches else "topic_not_found",
105
+ f"{'Several topics match' if matches else 'No topic matches'} {selector}. "
106
+ "Use an id from recala topics list.\n" + choices)
107
+ return matches[0]
108
+
109
+
110
+ def resolve_topic_id(client, selector: str) -> str:
111
+ validate_id(selector, "topic")
112
+ return resolve_topic(read_topics(client), selector)["id"] if is_short_id(selector) else selector
113
+
114
+
115
+ def _resolve_short(client, selector: str, *, endpoint, listed_key: str, noun: str, list_command: str, name_key: str) -> str:
116
+ """An 8-character prefix of an id the CLI printed, resolved against that list; anything else passes through."""
117
+ if not is_short_id(selector):
118
+ return selector
119
+ rows = (client.call(endpoint) or {}).get(listed_key) or []
120
+ matches = matching_prefix(rows, selector)
121
+ if len(matches) == 1:
122
+ return str(matches[0]["id"])
123
+ if matches:
124
+ choices = "\n".join(f"{display_name(row.get(name_key) or 'Unnamed')} [{row['id']}]" for row in matches)
125
+ raise CliError(ExitCode.USAGE, f"{noun}_ambiguous", f"Several {noun}s start with {selector}:\n{choices}")
126
+ raise CliError(ExitCode.USAGE, f"{noun}_not_found",
127
+ f"No {noun} starts with {selector}. Find its id with: recala {list_command}")
128
+
129
+
130
+ def resolve_person_id(client, selector: str) -> str:
131
+ """An author's 8-character id from `recala authors list`, or the full id."""
132
+ return _resolve_short(client, selector.strip(), endpoint=PEOPLE_LIST, listed_key="people", noun="author",
133
+ list_command="authors list", name_key="display_name")
134
+
135
+
136
+ def resolve_source_id(client, selector: str) -> str:
137
+ """A knowledge source's 8-character id from `recala knowledge list`, or the full id."""
138
+ return _resolve_short(client, selector.strip(), endpoint=KNOWLEDGE_LIST, listed_key="documents", noun="source",
139
+ list_command="knowledge list", name_key="filename")
recala_cli/labels.py ADDED
@@ -0,0 +1,74 @@
1
+ """Best-effort names for human gates, separate from the API's JSON answers."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from recala_cli.client import RecalaClient
9
+ from recala_cli.display import display_name
10
+ from recala_cli.endpoints import ARTICLE_SHOW, KEYS_LIST, KNOWLEDGE_LIST, PEOPLE_LIST, TOPIC_WHY
11
+ from recala_cli.errors import CliError
12
+
13
+
14
+ def _read_name(client: RecalaClient, kind: str, identifier: str) -> str | None:
15
+ if kind == "topic":
16
+ data = client.call(TOPIC_WHY, path={"item_id": identifier})
17
+ return data.get("topic") if isinstance(data, dict) else None
18
+ if kind == "article":
19
+ data = client.call(ARTICLE_SHOW, path={"article_id": identifier})
20
+ article = data.get("article") if isinstance(data, dict) else None
21
+ return (article.get("article_title") or article.get("topic")) if isinstance(article, dict) else None
22
+ endpoint, collection, field = {
23
+ "person": (PEOPLE_LIST, "people", "display_name"),
24
+ "key": (KEYS_LIST, "keys", "name"),
25
+ "source": (KNOWLEDGE_LIST, "documents", "filename"),
26
+ }[kind]
27
+ data = client.call(endpoint)
28
+ rows = data.get(collection) if isinstance(data, dict) else None
29
+ for row in rows if isinstance(rows, list) else []:
30
+ if isinstance(row, dict) and str(row.get("id")) == identifier:
31
+ return row.get(field)
32
+ return None
33
+
34
+
35
+ def resource_label(ctx: click.Context, client: RecalaClient, kind: str, identifier: str,
36
+ *, result_subject: bool = True) -> str:
37
+ """Name first, short id second. Failed reads keep a usable id and never veto a write.
38
+
39
+ JSON mode skips presentation reads entirely; pipes still need names. The subject lives in Click's
40
+ context, so adding a human name cannot change the command's JSON payload.
41
+ """
42
+ name = None
43
+ if not (ctx.find_root().obj or {}).get("as_json"):
44
+ try:
45
+ name = _read_name(client, kind, identifier)
46
+ except (CliError, ValueError):
47
+ pass
48
+ name = display_name(name)
49
+ fallback = f"article [{identifier[:8]}]" if kind == "article" and not ctx.obj.get("as_json") else f"{kind} {identifier}"
50
+ subject = f'"{name}"' if name else fallback
51
+ if result_subject:
52
+ ctx.meta["result_subject"] = subject
53
+ return f"{subject} [{identifier[:8]}]" if name else subject
54
+
55
+
56
+ def action_sentence(action: str) -> Callable[[click.Context, Any], str]:
57
+ """A contextual renderer for a named action, without decorating its JSON data."""
58
+ def render(ctx: click.Context, _result: Any) -> str:
59
+ return f"{action}: {ctx.meta['result_subject']}"
60
+ return render
61
+
62
+
63
+ def named_local_result(describe: Callable[[click.Context, Any], str]) -> Callable[[click.Context, Any], str]:
64
+ """named_result for a view that needs the context, for example to read the company's timezone."""
65
+ def render(ctx: click.Context, result: Any) -> str:
66
+ return f"{ctx.meta['result_subject']}\n{describe(ctx, result)}"
67
+ return render
68
+
69
+
70
+ def named_result(describe: Callable[[Any], str]) -> Callable[[click.Context, Any], str]:
71
+ """Keep a detail view attached to the resource the person selected."""
72
+ def render(ctx: click.Context, result: Any) -> str:
73
+ return f"{ctx.meta['result_subject']}\n{describe(result)}"
74
+ return render
recala_cli/main.py ADDED
@@ -0,0 +1,129 @@
1
+ """The `recala` command tree.
2
+
3
+ Nouns are groups (`recala company`, `recala topics`); verbs are their commands. The
4
+ root only resolves the API URL and the injectable seams tests replace: the HTTP
5
+ transport, the poll sleep, the browser opener and whether a person is at a terminal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import os
12
+ import time
13
+ import webbrowser
14
+
15
+ import click
16
+
17
+ from recala_cli import __version__, config
18
+ from recala_cli.commands.article import article
19
+ from recala_cli.commands.auth import grant, login, logout, whoami
20
+ from recala_cli.commands.billing import billing
21
+ from recala_cli.commands.bing import bing
22
+ from recala_cli.commands.cadence import autoapprove, cadence, plan
23
+ from recala_cli.commands.connect import cms, connect
24
+ from recala_cli.commands.credits import credits
25
+ from recala_cli.commands.gsc import gsc
26
+ from recala_cli.commands.index import index
27
+ from recala_cli.commands.keys import keys
28
+ from recala_cli.commands.languages import languages
29
+ from recala_cli.commands.knowledge import knowledge
30
+ from recala_cli.commands.monthly_report import monthly
31
+ from recala_cli.commands.notifications import notifications
32
+ from recala_cli.commands.onboard import onboard
33
+ from recala_cli.commands.org import org
34
+ from recala_cli.commands.profile import profile
35
+ from recala_cli.commands.questions import questions
36
+ from recala_cli.commands.refresh import refresh
37
+ from recala_cli.commands.reads import authors, calendar, report
38
+ from recala_cli.commands.slot_topic import replace_slot, waiting_slots
39
+ from recala_cli.commands.schema import schema
40
+ from recala_cli.commands.shell import completion, update
41
+ from recala_cli.commands.skills import skills
42
+ from recala_cli.commands.status import status
43
+ from recala_cli.commands.topics import topics
44
+ from recala_cli.runtime import interactive_human_output
45
+
46
+
47
+ class RecalaGroup(click.Group):
48
+ """Lists each group once: the aliases `org` (of company) and `author` (of authors) work but stay out of --help."""
49
+
50
+ UNLISTED_ALIASES = frozenset({"org", "author"})
51
+
52
+ def list_commands(self, ctx: click.Context) -> list[str]:
53
+ return [name for name in super().list_commands(ctx) if name not in self.UNLISTED_ALIASES]
54
+
55
+
56
+ @click.group(cls=RecalaGroup, invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
57
+ @click.version_option(__version__, prog_name="recala")
58
+ @click.option(
59
+ "--api-url", default=None,
60
+ help="API base URL (else RECALA_API_URL, else the config file, else production).",
61
+ )
62
+ @click.option(
63
+ "--org", "org_flag", default=None,
64
+ help="Company name, slug or id for this command (the same as --org after the command; that one wins).",
65
+ )
66
+ @click.pass_context
67
+ def cli(ctx: click.Context, api_url: str | None, org_flag: str | None) -> None:
68
+ """Drive a Recala company from the terminal, by hand or through an AI agent.
69
+ Start with: recala onboard
70
+ """
71
+ seams = ctx.ensure_object(dict)
72
+ seams.setdefault("transport", None)
73
+ seams.setdefault("sleep", time.sleep)
74
+ seams.setdefault("clock", time.time)
75
+ seams.setdefault("open_browser", webbrowser.open)
76
+ # None: decided from the terminal (confirm.is_interactive).
77
+ seams.setdefault("interactive", None)
78
+ seams["api_url"] = config.api_url(api_url)
79
+ seams["org_flag"] = org_flag
80
+ if ctx.invoked_subcommand is None:
81
+ if (not interactive_human_output(ctx) or
82
+ os.environ.get("RECALA_JSON", "").lower() not in ("", "0", "false", "no", "off")):
83
+ raise click.exceptions.NoArgsIsHelpError(ctx)
84
+ click.echo("Recala helps your company compete for answers "
85
+ "with articles signed by a named person.\n\n"
86
+ "1. Sign in: recala login\n"
87
+ "2. Choose your company: recala company use <name>\n"
88
+ "3. Preview your plan: recala plan\n\n"
89
+ "Everything: recala --help")
90
+
91
+
92
+ COMMANDS = (completion, update, onboard, status, login, logout, whoami, grant, org, authors, cms, connect, bing, gsc, knowledge,
93
+ credits, billing, topics, questions, keys, article, index, calendar, report, schema, skills,
94
+ cadence, autoapprove, plan, notifications, refresh)
95
+ for command in COMMANDS:
96
+ cli.add_command(command)
97
+ topics.add_command(waiting_slots)
98
+ topics.add_command(replace_slot)
99
+ cli.add_command(org, name="company")
100
+ cli.add_command(authors, name="author")
101
+ org.add_command(languages)
102
+ org.add_command(profile)
103
+ report.add_command(monthly)
104
+
105
+
106
+ def suggest_when_unknown(group: click.Group, seen: set[int] | None = None) -> None:
107
+ """An unknown command names the closest real one, in the root and in every group below it."""
108
+ seen = set() if seen is None else seen
109
+ if id(group) in seen:
110
+ return
111
+ seen.add(id(group))
112
+ resolve = group.resolve_command
113
+
114
+ def resolve_or_suggest(ctx: click.Context, args: list[str]):
115
+ name = args[0] if args else ""
116
+ if name and not name.startswith("-") and group.get_command(ctx, name) is None:
117
+ close = difflib.get_close_matches(name, group.list_commands(ctx), n=1, cutoff=0.6)
118
+ path = " ".join(["recala", *ctx.command_path.split()[1:]])
119
+ ctx.fail(f"No such command {name!r}. "
120
+ + (f"Did you mean {close[0]!r}?" if close else f"Run `{path} --help` to see every command."))
121
+ return resolve(ctx, args)
122
+
123
+ group.resolve_command = resolve_or_suggest # type: ignore[method-assign]
124
+ for command in group.commands.values():
125
+ if isinstance(command, click.Group):
126
+ suggest_when_unknown(command, seen)
127
+
128
+
129
+ suggest_when_unknown(cli)