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/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)
|