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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`recala skills`: the agent skills that ship with this CLI, same version, installed where the agent looks.
|
|
2
|
+
|
|
3
|
+
The skills travel inside the package (``recala_cli/skills``), so a skill never names a
|
|
4
|
+
command this CLI does not have (tests/test_skills.py checks every command they use).
|
|
5
|
+
``install`` copies each skill and gives it the shared CLI contract as
|
|
6
|
+
``references/recala-cli-contract.md``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from recala_cli import __version__
|
|
19
|
+
from recala_cli.errors import CliError, ExitCode
|
|
20
|
+
from recala_cli.runtime import command_runner, json_option
|
|
21
|
+
|
|
22
|
+
CONTRACT = "recala-cli-contract.md"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bundled() -> Path:
|
|
26
|
+
return Path(str(resources.files("recala_cli") / "skills"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def frontmatter(skill_md: Path) -> dict[str, str]:
|
|
30
|
+
"""The ``name`` and ``description`` of a SKILL.md (Agent Skills format), plus its version."""
|
|
31
|
+
text = skill_md.read_text()
|
|
32
|
+
if not text.startswith("---\n"):
|
|
33
|
+
return {}
|
|
34
|
+
head = text[4:text.index("\n---", 4)]
|
|
35
|
+
fields: dict[str, str] = {}
|
|
36
|
+
for line in head.splitlines():
|
|
37
|
+
key, _, value = line.strip().partition(":")
|
|
38
|
+
if key in ("name", "description", "version", "requires") and value.strip():
|
|
39
|
+
fields[key] = value.strip()
|
|
40
|
+
return fields
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def skill_dirs() -> list[Path]:
|
|
44
|
+
return sorted(path for path in bundled().iterdir() if (path / "SKILL.md").is_file())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
def skills() -> None:
|
|
49
|
+
"""Agent skills for Claude Code, Codex and others.
|
|
50
|
+
|
|
51
|
+
SKILL.md files, versioned with this CLI."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@skills.command("list")
|
|
55
|
+
@json_option
|
|
56
|
+
@command_runner(human=lambda r: "\n".join(f"{s['name']:<34} {s['version']}" for s in r["skills"]))
|
|
57
|
+
def list_skills(ctx: click.Context) -> dict[str, Any]:
|
|
58
|
+
"""The skills this CLI ships."""
|
|
59
|
+
listed = []
|
|
60
|
+
for path in skill_dirs():
|
|
61
|
+
meta = frontmatter(path / "SKILL.md")
|
|
62
|
+
listed.append({"name": meta.get("name", path.name), "version": meta.get("version", ""),
|
|
63
|
+
"description": meta.get("description", "")})
|
|
64
|
+
return {"cli_version": __version__, "skills": listed}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@skills.command("install")
|
|
68
|
+
@json_option
|
|
69
|
+
@command_runner(human=lambda r: "\n".join(r["installed"]) + f"\n{len(r['installed'])} skills in {r['dir']}")
|
|
70
|
+
@click.option("--dir", "target", type=click.Path(file_okay=False, path_type=Path), default=Path(".claude/skills"),
|
|
71
|
+
show_default=True, help="Where the agent reads skills (a project's .claude/skills, or ~/.claude/skills).")
|
|
72
|
+
@click.option("--force", is_flag=True, help="Replace skills already installed there.")
|
|
73
|
+
def install(ctx: click.Context, target: Path, force: bool) -> dict[str, Any]:
|
|
74
|
+
"""Copy every skill into --dir.
|
|
75
|
+
|
|
76
|
+
With the CLI contract as a reference."""
|
|
77
|
+
sources = skill_dirs()
|
|
78
|
+
clashes = [path.name for path in sources if (target / path.name).exists()]
|
|
79
|
+
if clashes and not force:
|
|
80
|
+
raise CliError(ExitCode.CONFLICT, "already_installed",
|
|
81
|
+
f"Already installed in {target}: {', '.join(clashes)}. Re-run with --force to replace them.")
|
|
82
|
+
installed = []
|
|
83
|
+
for source in sources:
|
|
84
|
+
destination = target / source.name
|
|
85
|
+
if destination.exists():
|
|
86
|
+
shutil.rmtree(destination)
|
|
87
|
+
shutil.copytree(source, destination)
|
|
88
|
+
references = destination / "references"
|
|
89
|
+
references.mkdir(exist_ok=True)
|
|
90
|
+
shutil.copyfile(bundled() / CONTRACT, references / CONTRACT)
|
|
91
|
+
installed.append(str(destination))
|
|
92
|
+
return {"dir": str(target), "installed": installed, "cli_version": __version__}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`recala topics waiting-slots` and `replace-slot`: a date that stopped and waits for a person."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from recala_cli.display import display_name
|
|
10
|
+
from recala_cli.endpoints import ESCALATED_SLOTS, SLOT_TOPIC_REPLACE
|
|
11
|
+
from recala_cli.errors import CliError, ExitCode
|
|
12
|
+
from recala_cli.ids import is_short_id, matching_prefix
|
|
13
|
+
from recala_cli.labels import action_sentence
|
|
14
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option
|
|
15
|
+
from recala_cli.views import company_timezone, local_time, timezone_label
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def replace_command(slot_id: str) -> str:
|
|
19
|
+
return f"recala topics replace-slot {slot_id[:8]} <topic>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def describe_waiting(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
23
|
+
if not result["slots"]:
|
|
24
|
+
return "No date is waiting for a person."
|
|
25
|
+
zone = company_timezone(ctx, result)
|
|
26
|
+
lines = [f"Dates waiting for a person {timezone_label(zone)}"]
|
|
27
|
+
for slot in result["slots"]:
|
|
28
|
+
lines.append(f" {local_time(slot['scheduled_at'], zone)} {display_name(slot.get('topic')) or '(no topic)'}")
|
|
29
|
+
lines.append(f" {slot['reason']}")
|
|
30
|
+
lines.append(f" {slot['refusal_message'] or 'Replace it: ' + replace_command(slot['id'])}")
|
|
31
|
+
if result["candidates"]:
|
|
32
|
+
lines.append("Topics these dates may take:")
|
|
33
|
+
lines.extend(f" {display_name(row['topic'])} [{row['id'][:8]}]" for row in result["candidates"])
|
|
34
|
+
else:
|
|
35
|
+
lines.append("No topic in Up next can take these dates. Add one: recala topics up-next add <title>")
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_slot(slots: list[dict[str, Any]], selector: str) -> dict[str, Any]:
|
|
40
|
+
matches = [row for row in slots if row["id"] == selector] or (
|
|
41
|
+
matching_prefix(slots, selector) if is_short_id(selector) else [])
|
|
42
|
+
if len(matches) != 1:
|
|
43
|
+
raise CliError(ExitCode.USAGE, "slot_ambiguous" if matches else "slot_not_found",
|
|
44
|
+
f"{'Several dates match' if matches else 'No date waiting for a person matches'} {selector}. "
|
|
45
|
+
"See: recala topics waiting-slots")
|
|
46
|
+
return matches[0]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_candidate(candidates: list[dict[str, Any]], selector: str) -> dict[str, Any]:
|
|
50
|
+
matches = [row for row in candidates if row["id"] == selector] or (
|
|
51
|
+
matching_prefix(candidates, selector) if is_short_id(selector) else
|
|
52
|
+
[row for row in candidates if str(row["topic"]).casefold() == selector.casefold()])
|
|
53
|
+
if len(matches) == 1:
|
|
54
|
+
return matches[0]
|
|
55
|
+
choices = "\n".join(f"{display_name(row['topic'])} [{row['id'][:8]}]" for row in candidates)
|
|
56
|
+
raise CliError(ExitCode.USAGE, "replacement_not_writable",
|
|
57
|
+
f"This date can't take {selector}. It can take:\n{choices or 'no topic right now'}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@click.command("waiting-slots")
|
|
61
|
+
@common_options
|
|
62
|
+
@command_runner(contextual_human=describe_waiting)
|
|
63
|
+
def waiting_slots(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
64
|
+
"""Dates waiting for a person, and their topics."""
|
|
65
|
+
result: dict[str, Any] = open_client(ctx, org_flag).call(ESCALATED_SLOTS)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.command("replace-slot")
|
|
70
|
+
@common_options
|
|
71
|
+
@command_runner(contextual_human=action_sentence("Topic replaced"))
|
|
72
|
+
@click.argument("slot")
|
|
73
|
+
@click.argument("topic")
|
|
74
|
+
@accepted_yes_option
|
|
75
|
+
@dry_run_option
|
|
76
|
+
def replace_slot(ctx: click.Context, org_flag: str | None, slot: str, topic: str) -> dict[str, Any]:
|
|
77
|
+
"""Replace the topic on a date waiting for a person.
|
|
78
|
+
|
|
79
|
+
SLOT is an id from `recala topics waiting-slots`. TOPIC is an id or exact title from that list."""
|
|
80
|
+
client = open_client(ctx, org_flag)
|
|
81
|
+
waiting = client.call(ESCALATED_SLOTS)
|
|
82
|
+
row = resolve_slot(waiting["slots"], slot)
|
|
83
|
+
if row["refusal"]:
|
|
84
|
+
raise CliError(ExitCode.CONFLICT, row["refusal"], row["refusal_message"])
|
|
85
|
+
replacement = resolve_candidate(waiting["candidates"], topic)
|
|
86
|
+
day = local_time(row["scheduled_at"], company_timezone(ctx, waiting))
|
|
87
|
+
title = display_name(replacement["topic"])
|
|
88
|
+
ctx.meta["result_subject"] = f"{day} will publish {title}"
|
|
89
|
+
confirm(ctx, client, f"Replace \"{display_name(row.get('topic'))}\" with \"{title}\" on {day}?")
|
|
90
|
+
result: dict[str, Any] = client.call(
|
|
91
|
+
SLOT_TOPIC_REPLACE, path={"slot_id": row["id"]},
|
|
92
|
+
json={"topic_id": replacement["id"], "expected_topic_id": row["topic_id"]},
|
|
93
|
+
)
|
|
94
|
+
return result
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""`recala status`: the active company at a glance.
|
|
2
|
+
|
|
3
|
+
The plan with its caps and reset date, the connections with their next steps, the
|
|
4
|
+
question panel, the articles, what is scheduled, and where onboarding stands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from recala_cli import ui
|
|
17
|
+
from recala_cli.endpoints import BILLING_MANAGED_USAGE, ENTITLEMENTS, CADENCE_SHOW, PUBLICATION_PLAN, AUTOAPPROVE_SHOW
|
|
18
|
+
from recala_cli.onboarding import Facts, evaluate, gather, next_step
|
|
19
|
+
from recala_cli.commands.index import is_indexed, read_indexing
|
|
20
|
+
from recala_cli.errors import CliError
|
|
21
|
+
from recala_cli.parallel import run_parallel
|
|
22
|
+
from recala_cli.human import publish_state_words
|
|
23
|
+
from recala_cli.commands.reads import calendar_window
|
|
24
|
+
from recala_cli.runtime import command_runner, common_options, open_client, optional_read
|
|
25
|
+
from recala_cli.views import calendar_view, connection_lines, credit_lines, panel_lines, planned_calendar
|
|
26
|
+
|
|
27
|
+
_PUBLISHED_STATES = {"published", "updated"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def dashboard(facts: Facts, *, entitlements: dict[str, Any] | None, managed: dict[str, Any] | None,
|
|
31
|
+
indexing: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
32
|
+
rows = evaluate(facts)
|
|
33
|
+
states = Counter(str(a.get("cms_publish_status") or "draft") for a in facts.articles)
|
|
34
|
+
return {
|
|
35
|
+
"org": facts.org, "user": (facts.me or {}).get("user"),
|
|
36
|
+
"plan": {"summary": facts.summary, "entitlements": entitlements, "managed_usage": managed},
|
|
37
|
+
"connections": {"cms": facts.cms, "search_console": facts.gsc, "org": facts.org,
|
|
38
|
+
**({"sanity": facts.sanity} if facts.sanity else {})},
|
|
39
|
+
"questions": facts.panel,
|
|
40
|
+
"articles": {"total": len(facts.articles), "by_publish_status": dict(states),
|
|
41
|
+
"topics_waiting": int(facts.topics.get("total") or 0)},
|
|
42
|
+
"calendar": planned_calendar(facts.calendar, facts.publication_plan or facts.cadence),
|
|
43
|
+
"autopilot_blockers": autopilot_blockers(facts),
|
|
44
|
+
"indexing": indexing,
|
|
45
|
+
"onboarding": {"done": sum(1 for row in rows if row["done"]), "total": len(rows), "next": next_step(rows),
|
|
46
|
+
"missing": [row for row in rows if not row["done"]]},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def describe_dashboard(board: dict[str, Any], *, cadence: dict[str, Any] | None = None) -> str:
|
|
51
|
+
style = ui.Style.for_stream(sys.stdout) # the dashboard is printed on stdout
|
|
52
|
+
org = board.get("org") or {}
|
|
53
|
+
calendar = board.get("calendar") or {}
|
|
54
|
+
zone = org.get("timezone") or (cadence or {}).get("timezone")
|
|
55
|
+
if not zone and calendar.get("timezone") != "UTC":
|
|
56
|
+
zone = calendar.get("timezone")
|
|
57
|
+
title = f"{style.accent('*')} {style.bold(org.get('name') or 'Your company')}"
|
|
58
|
+
lines = [title + style.dim(f" {org.get('website_url') or ''} {org.get('language') or ''}"), ""]
|
|
59
|
+
plan = board.get("plan") or {}
|
|
60
|
+
lines += [style.bold("Plan and credits")]
|
|
61
|
+
lines += [f" {line}" for line in credit_lines(plan.get("summary") or {}, plan.get("entitlements"),
|
|
62
|
+
plan.get("managed_usage"), show_question_cap=False, zone=zone or "UTC")]
|
|
63
|
+
lines += ["", style.bold("Connections")]
|
|
64
|
+
lines += [f" {line}" for line in connection_lines(board.get("connections") or {}, zone=zone)]
|
|
65
|
+
lines += ["", style.bold("Questions")]
|
|
66
|
+
lines += [f" {line}" for line in panel_lines(board.get("questions") or {}, plan.get("entitlements"))]
|
|
67
|
+
articles = board.get("articles") or {}
|
|
68
|
+
lines += ["", style.bold("Articles")]
|
|
69
|
+
in_words: Counter[str] = Counter()
|
|
70
|
+
for state, count in (articles.get("by_publish_status") or {}).items():
|
|
71
|
+
in_words[publish_state_words(state)] += count
|
|
72
|
+
counts = ", ".join(f"{n} {state}" for state, n in sorted(in_words.items()))
|
|
73
|
+
total = articles.get("total", 0)
|
|
74
|
+
lines.append(f" {total} article{'' if total == 1 else 's'}" + (f" ({counts})" if counts else "")
|
|
75
|
+
+ f"; {articles.get('topics_waiting', 0)} topics waiting -> recala topics list")
|
|
76
|
+
lines.append(" " + indexing_line(board.get("indexing")))
|
|
77
|
+
if calendar:
|
|
78
|
+
calendar = {**calendar, "timezone": zone}
|
|
79
|
+
lines += [""] + [f" {line}" if index else style.bold(line)
|
|
80
|
+
for index, line in enumerate(calendar_view(calendar, style=style, cadence=cadence).splitlines())]
|
|
81
|
+
onboarding = board.get("onboarding") or {}
|
|
82
|
+
lines += ["", style.bold("Onboarding") + f" {onboarding.get('done')}/{onboarding.get('total')} steps"]
|
|
83
|
+
upcoming = onboarding.get("next")
|
|
84
|
+
lines.append(f" Next: {upcoming['title']} -> {upcoming['command']}" if upcoming
|
|
85
|
+
else " Complete.")
|
|
86
|
+
lines.extend(f" Missing: {row['title']} -> {row['command']}" for row in onboarding.get("missing", []) if row != upcoming)
|
|
87
|
+
if board.get("autopilot_blockers"):
|
|
88
|
+
lines += ["", "Autopilot is on but cannot publish:"]
|
|
89
|
+
lines.extend(" " + reason for reason in board["autopilot_blockers"])
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def autopilot_blockers(facts):
|
|
94
|
+
from recala_cli.onboarding import topic_choices
|
|
95
|
+
if not (facts.cadence.get("cadence") or {}).get("autopilot_enabled"):
|
|
96
|
+
return []
|
|
97
|
+
reasons = []
|
|
98
|
+
if not (facts.cms or {}).get("connected"):
|
|
99
|
+
reasons.append("No CMS connected -> recala cms connect --provider <sanity|wordpress>")
|
|
100
|
+
if not any(row.get("review_decision") == "approved" for row in topic_choices(facts)):
|
|
101
|
+
reasons.append("No approved topics -> recala topics approve <id>; then recala topics up-next add <title>")
|
|
102
|
+
active_ids = {person.get("id") for person in facts.people if person.get("is_active", True)}
|
|
103
|
+
if facts.approval.get("default_author_person_id") not in active_ids:
|
|
104
|
+
reasons.append("No active signer -> recala authors add --name <name>; recala autoapprove on --author <id>")
|
|
105
|
+
if facts.scout.get("state") == "failed":
|
|
106
|
+
reasons.append(f"Scout failing ({facts.scout.get('error_code') or 'failed'}) -> recala topics scout")
|
|
107
|
+
return reasons
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@click.command()
|
|
111
|
+
@common_options
|
|
112
|
+
@command_runner(contextual_human=lambda ctx, board: describe_dashboard(board, cadence=ctx.meta.get("status_cadence")))
|
|
113
|
+
def status(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
114
|
+
"""The active company at a glance.
|
|
115
|
+
|
|
116
|
+
Plan and caps, connections, questions, articles, calendar, onboarding."""
|
|
117
|
+
client = open_client(ctx, org_flag)
|
|
118
|
+
# Every question that does not depend on another is asked at once; indexing waits for the article list.
|
|
119
|
+
answers = run_parallel({
|
|
120
|
+
"facts": lambda: gather(client, calendar_window(), parallel=True),
|
|
121
|
+
"cadence": lambda: optional_read(client, CADENCE_SHOW) or {},
|
|
122
|
+
"approval": lambda: optional_read(client, AUTOAPPROVE_SHOW) or {},
|
|
123
|
+
"plan": lambda: optional_read(client, PUBLICATION_PLAN, params={"weeks": 2}) or {},
|
|
124
|
+
"entitlements": lambda: optional_read(client, ENTITLEMENTS),
|
|
125
|
+
"managed": lambda: optional_read(client, BILLING_MANAGED_USAGE),
|
|
126
|
+
})
|
|
127
|
+
facts = answers["facts"]
|
|
128
|
+
facts.cadence, facts.approval, facts.publication_plan = answers["cadence"], answers["approval"], answers["plan"]
|
|
129
|
+
ctx.meta["status_cadence"] = (facts.publication_plan.get("cadence") or facts.cadence.get("cadence") or {})
|
|
130
|
+
entitlements = answers["entitlements"]
|
|
131
|
+
return dashboard(facts, entitlements=entitlements if (entitlements or {}).get("enabled") else None,
|
|
132
|
+
managed=answers["managed"], indexing=indexing_of_listed(client, facts.articles))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def indexing_of_listed(client, articles: list[dict[str, Any]], limit: int = 20) -> dict[str, Any] | None:
|
|
136
|
+
"""Indexing for the dashboard from the article list already read: one inspection per published article, together."""
|
|
137
|
+
ids = [str(a["id"]) for a in articles if a.get("cms_publish_status") in _PUBLISHED_STATES][:limit]
|
|
138
|
+
try:
|
|
139
|
+
states = list(run_parallel({article_id: partial(read_indexing, client, article_id) for article_id in ids}).values())
|
|
140
|
+
except CliError as error:
|
|
141
|
+
return None if error.code == "indexing_not_enabled" else {"unavailable": error.message}
|
|
142
|
+
return {"published": len(ids), "indexed": sum(1 for state in states if is_indexed(state))}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def indexing_line(indexing: dict[str, Any] | None) -> str:
|
|
146
|
+
if indexing is None:
|
|
147
|
+
return "Indexing: tracker not enabled yet"
|
|
148
|
+
if indexing.get("unavailable"):
|
|
149
|
+
return f"Indexing: unavailable right now ({indexing['unavailable']})"
|
|
150
|
+
if not indexing.get("published"):
|
|
151
|
+
return "Indexing: no published articles yet"
|
|
152
|
+
return (f"Indexing: {indexing['indexed']} of {indexing['published']} published articles indexed in Google"
|
|
153
|
+
" -> recala index status --all-published")
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""`recala topics list`: the topic queue, as a projection agents can rely on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from recala_cli.client import RecalaClient
|
|
11
|
+
from recala_cli.endpoints import TOPIC_ADD, TOPIC_APPROVE, TOPIC_DISMISS, TOPIC_WHY, TOPICS_LIST, TOPICS_SCOUT, TOPICS_SCOUT_STATUS, UP_NEXT_ADD, UP_NEXT_REMOVE, UP_NEXT_REORDER
|
|
12
|
+
from recala_cli.errors import CliError, ExitCode
|
|
13
|
+
from recala_cli.human import describe_fields
|
|
14
|
+
from recala_cli.ids import TOPIC_SECTIONS as SECTIONS, id_arguments, read_topics, resolve_topic, resolve_topic_id
|
|
15
|
+
from recala_cli.labels import action_sentence, resource_label
|
|
16
|
+
from recala_cli.polling import poll_until
|
|
17
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option, accepted_yes_option, dry_run_option
|
|
18
|
+
from recala_cli.views import pay_link_step, company_timezone, engine_name, local_time, timezone_label
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_topic(row: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
"""The CLI's promise per topic. The API row's internal fields (rationale, decay) stay out."""
|
|
23
|
+
return {
|
|
24
|
+
"id": row.get("id"),
|
|
25
|
+
"topic": row.get("topic"),
|
|
26
|
+
"status": row.get("status"),
|
|
27
|
+
"archetype": row.get("archetype"),
|
|
28
|
+
"score": row.get("effective_score"),
|
|
29
|
+
"article_id": row.get("article_id"),
|
|
30
|
+
"created_at": row.get("created_at"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: A person reads this many topics per section; `--limit 0` shows the rest.
|
|
35
|
+
PEOPLE_SECTION_ROWS = 20
|
|
36
|
+
QUEUE_PAGE = 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_queue(client, *, limit: int | None = None, offset: int = 0) -> dict[str, Any]:
|
|
40
|
+
"""The queue in as few calls as the API allows: one page of QUEUE_PAGE unless the backlog is longer."""
|
|
41
|
+
if limit:
|
|
42
|
+
return read_topics(client, limit=limit, offset=offset)
|
|
43
|
+
queue: dict[str, Any] = {name: [] for name in SECTIONS}
|
|
44
|
+
while True:
|
|
45
|
+
page = client.call(TOPICS_LIST, params={"limit": QUEUE_PAGE, "offset": offset})
|
|
46
|
+
for name in SECTIONS:
|
|
47
|
+
known = {row.get("id") for row in queue[name]}
|
|
48
|
+
queue[name] += [row for row in page.get(name) or [] if row.get("id") not in known]
|
|
49
|
+
queue["total"] = page.get("total") or 0
|
|
50
|
+
offset += QUEUE_PAGE
|
|
51
|
+
if offset >= queue["total"] or page.get("gated_count"):
|
|
52
|
+
return queue
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _describe_topics(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
56
|
+
show_all = ctx.meta.get("topics_show_all", False)
|
|
57
|
+
lines = []
|
|
58
|
+
for section in SECTIONS:
|
|
59
|
+
rows = result.get(section) or []
|
|
60
|
+
if rows:
|
|
61
|
+
shown = rows if show_all else rows[:PEOPLE_SECTION_ROWS]
|
|
62
|
+
lines.append(f"{section.replace('_', ' ').capitalize()} ({len(rows)})")
|
|
63
|
+
lines.extend(f" {row['topic']} [{str(row['id'])[:8]}]" for row in shown)
|
|
64
|
+
if len(shown) < len(rows):
|
|
65
|
+
lines.append(f" {len(rows) - len(shown)} more: recala topics list --limit 0")
|
|
66
|
+
lines.append(f"{result.get('total', 0)} topics in total")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.group()
|
|
71
|
+
def topics() -> None:
|
|
72
|
+
"""Topics: what Recala proposes to write next."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scout_needs_plan(error: CliError) -> CliError:
|
|
76
|
+
"""The server's refusal names the web billing page; the CLI names the command that makes a pay link."""
|
|
77
|
+
message = f"Topic scouting starts once a plan is active: {pay_link_step()}, then retry topic scouting."
|
|
78
|
+
details = {**(error.details or {}), "next": {"command": "recala topics scout", "human_action": {
|
|
79
|
+
"what": message, "where": "recala billing link --plan go"}}}
|
|
80
|
+
return CliError(error.exit_code, error.code, message, status=error.status, details=details)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scout_topics(ctx: click.Context, client: RecalaClient) -> dict[str, Any]:
|
|
84
|
+
"""Resume a running scout, otherwise confirm a new one; preserve the server's gates."""
|
|
85
|
+
state = client.call(TOPICS_SCOUT_STATUS)
|
|
86
|
+
if state.get("state") != "running":
|
|
87
|
+
confirm(ctx, client, "Scout the first topics? This uses the company's recommendation allowance.")
|
|
88
|
+
try:
|
|
89
|
+
client.call(TOPICS_SCOUT, json={})
|
|
90
|
+
except CliError as error:
|
|
91
|
+
if error.code == "plan_activation_required":
|
|
92
|
+
raise _scout_needs_plan(error) from error
|
|
93
|
+
if error.code != "scout_running":
|
|
94
|
+
raise
|
|
95
|
+
result = poll_until(ctx, lambda: client.call(TOPICS_SCOUT_STATUS),
|
|
96
|
+
lambda r: r.get("state") in ("done", "failed"), label="Scouting topics",
|
|
97
|
+
timeout_seconds=960)
|
|
98
|
+
if result.get("state") == "failed":
|
|
99
|
+
raise CliError(ExitCode.CONFLICT, result.get("error_code") or "scout_failed",
|
|
100
|
+
result.get("error_message") or "Topic scouting did not finish. Run recala topics scout to retry.")
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@topics.command("scout")
|
|
105
|
+
@common_options
|
|
106
|
+
@command_runner(human=lambda r: f"Scout complete: {r.get('found', 0)} topics found. See: recala topics list")
|
|
107
|
+
@accepted_yes_option
|
|
108
|
+
@dry_run_option
|
|
109
|
+
def scout(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
110
|
+
"""Find new topics with the background scout.
|
|
111
|
+
|
|
112
|
+
The server's payment and entity gates apply."""
|
|
113
|
+
return scout_topics(ctx, open_client(ctx, org_flag))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@topics.command("list")
|
|
117
|
+
@common_options
|
|
118
|
+
@command_runner(contextual_human=_describe_topics)
|
|
119
|
+
@click.option("--section", type=click.Choice(SECTIONS), default=None, help="Only this section of the queue.")
|
|
120
|
+
@click.option("--limit", type=click.IntRange(0, 200), default=None,
|
|
121
|
+
help="Fetch one page of this size. Default: every topic in the data, 20 per section on screen; 0 shows all.")
|
|
122
|
+
@click.option("--offset", type=click.IntRange(0), default=0, show_default=True)
|
|
123
|
+
def list_topics(ctx: click.Context, org_flag: str | None, section: str | None, limit: int | None, offset: int) -> dict[str, Any]:
|
|
124
|
+
"""The topic queue, by section.
|
|
125
|
+
|
|
126
|
+
Up next, backlog, queued, processing, other."""
|
|
127
|
+
client = open_client(ctx, org_flag)
|
|
128
|
+
queue = read_queue(client, limit=limit, offset=offset)
|
|
129
|
+
ctx.meta["topics_show_all"] = limit is not None
|
|
130
|
+
wanted = (section,) if section else SECTIONS
|
|
131
|
+
result: dict[str, Any] = {name: [project_topic(row) for row in queue.get(name) or []] for name in wanted}
|
|
132
|
+
result["total"] = queue.get("total", 0)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _describe_added_topic(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
137
|
+
title = ctx.meta["added_topic"]
|
|
138
|
+
position = result.get("position")
|
|
139
|
+
if position is None:
|
|
140
|
+
return f"Added: {title}"
|
|
141
|
+
destination = "the top of Up next" if position == 1 else "Up next"
|
|
142
|
+
return f"Added to {destination} ({position} of {result.get('up_next_count', '?')}): {title}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@topics.command("add")
|
|
146
|
+
@common_options
|
|
147
|
+
@command_runner(contextual_human=_describe_added_topic)
|
|
148
|
+
@click.argument("topic")
|
|
149
|
+
@accepted_yes_option
|
|
150
|
+
@dry_run_option
|
|
151
|
+
def add_topic(ctx: click.Context, org_flag: str | None, topic: str) -> dict[str, Any]:
|
|
152
|
+
"""Add your own topic (a buyer question) to the top of Up next."""
|
|
153
|
+
client = open_client(ctx, org_flag)
|
|
154
|
+
confirm(ctx, client, f"Add the topic \"{topic}\" to Up next?")
|
|
155
|
+
added: dict[str, Any] = client.call(TOPIC_ADD, json={"topic": topic})
|
|
156
|
+
ctx.meta["added_topic"] = (added.get("item") or {}).get("topic") or added.get("topic") or topic
|
|
157
|
+
return added
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@topics.command("approve")
|
|
161
|
+
@common_options
|
|
162
|
+
@command_runner(contextual_human=lambda ctx, r: action_sentence("Approved")(ctx, r) +
|
|
163
|
+
"\nNext: recala topics up-next add " + ctx.meta["up_next_selector"])
|
|
164
|
+
@click.argument("topic_id")
|
|
165
|
+
@id_arguments(topic_id="topic")
|
|
166
|
+
@accepted_yes_option
|
|
167
|
+
@dry_run_option
|
|
168
|
+
def approve_topic(ctx: click.Context, org_flag: str | None, topic_id: str) -> dict[str, Any]:
|
|
169
|
+
"""Keep a proposed topic."""
|
|
170
|
+
client = open_client(ctx, org_flag)
|
|
171
|
+
topic_id = resolve_topic_id(client, topic_id)
|
|
172
|
+
label = resource_label(ctx, client, "topic", topic_id)
|
|
173
|
+
from shlex import quote
|
|
174
|
+
subject = ctx.meta.get("result_subject", "")
|
|
175
|
+
ctx.meta["up_next_selector"] = quote(subject[1:-1] if subject.startswith('"') else topic_id)
|
|
176
|
+
confirm(ctx, client, f"Approve {label}?", machine_summary=f"Keep topic {topic_id}?")
|
|
177
|
+
kept: dict[str, Any] = client.call(TOPIC_APPROVE, path={"queue_id": topic_id})
|
|
178
|
+
return kept
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@topics.command("dismiss")
|
|
182
|
+
@common_options
|
|
183
|
+
@command_runner(contextual_human=action_sentence("Dropped"))
|
|
184
|
+
@click.argument("topic_id")
|
|
185
|
+
@id_arguments(topic_id="topic")
|
|
186
|
+
@accepted_yes_option
|
|
187
|
+
@dry_run_option
|
|
188
|
+
def dismiss_topic(ctx: click.Context, org_flag: str | None, topic_id: str) -> dict[str, Any]:
|
|
189
|
+
"""Drop a proposed topic from the queue."""
|
|
190
|
+
client = open_client(ctx, org_flag)
|
|
191
|
+
topic_id = resolve_topic_id(client, topic_id)
|
|
192
|
+
label = resource_label(ctx, client, "topic", topic_id)
|
|
193
|
+
confirm(ctx, client, f"Drop {label} from the queue?")
|
|
194
|
+
dropped: dict[str, Any] = client.call(TOPIC_DISMISS, path={"queue_id": topic_id})
|
|
195
|
+
return dropped
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
#: What `recala topics why` tells a person whose topic waits for the company's own material.
|
|
199
|
+
MATERIAL_HINT = ("This topic needs the company's own material: upload figures, cases or reports "
|
|
200
|
+
"with: recala knowledge add <file>")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _without_empty(value: Any) -> Any:
|
|
204
|
+
"""Drop blank strings, nulls, empty records and an unclassified kind, which a person cannot act on."""
|
|
205
|
+
if isinstance(value, dict):
|
|
206
|
+
kept = {key: _without_empty(item) for key, item in value.items()
|
|
207
|
+
if not (key == "kind" and item == "unknown")}
|
|
208
|
+
return {key: item for key, item in kept.items() if item not in (None, "", [], {})}
|
|
209
|
+
if isinstance(value, list):
|
|
210
|
+
return [item for item in map(_without_empty, value) if item not in (None, "", [], {})]
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def describe_topic_why(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
215
|
+
"""Humanise evidence enums and instants, preserving free text, URLs and JSON."""
|
|
216
|
+
zone = company_timezone(ctx, result)
|
|
217
|
+
has_time = False
|
|
218
|
+
names = {"coverage_gap": "coverage gap", "exa_search": "Exa search", "trends": "search trends"}
|
|
219
|
+
enum_fields = {"kind", "lane", "basis", "status", "source", "engine", "archetype", "reason", "type",
|
|
220
|
+
"reason_code", "required", "reachable", "tier", "level", "floor", "state", "missing"}
|
|
221
|
+
|
|
222
|
+
def human_value(value, field=""):
|
|
223
|
+
nonlocal has_time
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
return {key: human_value(item, key) for key, item in value.items()}
|
|
226
|
+
if isinstance(value, list):
|
|
227
|
+
return [human_value(item, field) for item in value]
|
|
228
|
+
if isinstance(value, str):
|
|
229
|
+
if re.match(r"^\d{4}-\d\d-\d\dT\d\d:", value):
|
|
230
|
+
has_time = True
|
|
231
|
+
return local_time(value, zone)
|
|
232
|
+
if field in enum_fields and re.fullmatch(r"[a-z][a-z0-9_]*", value, re.IGNORECASE):
|
|
233
|
+
return names.get(value.lower(), engine_name(value.lower()))
|
|
234
|
+
return value
|
|
235
|
+
|
|
236
|
+
details = {key: value for key, value in result.items() if key not in ("id", "topic")}
|
|
237
|
+
text = describe_fields(human_value(_without_empty(details)))
|
|
238
|
+
lines = []
|
|
239
|
+
if result.get("topic"):
|
|
240
|
+
lines.append(result["topic"] + (f" [{str(result['id'])[:8]}]" if result.get("id") else ""))
|
|
241
|
+
if has_time:
|
|
242
|
+
lines.append(timezone_label(zone))
|
|
243
|
+
readiness = result.get("topic_material_readiness") or {}
|
|
244
|
+
hint = [MATERIAL_HINT] if readiness.get("state") == "needs_company_material" else []
|
|
245
|
+
return "\n".join([*lines, text, *hint])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@topics.command("why")
|
|
249
|
+
@common_options
|
|
250
|
+
@command_runner(contextual_human=describe_topic_why)
|
|
251
|
+
@click.argument("topic_id")
|
|
252
|
+
@id_arguments(topic_id="topic")
|
|
253
|
+
def why_topic(ctx: click.Context, org_flag: str | None, topic_id: str) -> dict[str, Any]:
|
|
254
|
+
"""Why Recala proposed this topic: the evidence behind it."""
|
|
255
|
+
client = open_client(ctx, org_flag)
|
|
256
|
+
topic_id = resolve_topic_id(client, topic_id)
|
|
257
|
+
rationale: dict[str, Any] = client.call(TOPIC_WHY, path={"item_id": topic_id})
|
|
258
|
+
return rationale
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@topics.group("up-next", invoke_without_command=True)
|
|
262
|
+
@common_options
|
|
263
|
+
@click.pass_context
|
|
264
|
+
def up_next(ctx, org_flag, as_json):
|
|
265
|
+
"""List and arrange the topics autopilot will write."""
|
|
266
|
+
if ctx.invoked_subcommand is None:
|
|
267
|
+
ctx.invoke(list_up_next, org_flag=org_flag, as_json=as_json)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@up_next.command("list")
|
|
271
|
+
@common_options
|
|
272
|
+
@command_runner(human=lambda r: "\n".join(f"{i}. {row['topic']} [{str(row['id'])[:8]}]" for i, row in enumerate(r['up_next'], 1)) or "Up next is empty. Add a topic: recala topics up-next add <title>")
|
|
273
|
+
def list_up_next(ctx, org_flag):
|
|
274
|
+
"""List the ordered topics in Up next."""
|
|
275
|
+
data = read_queue(open_client(ctx, org_flag))
|
|
276
|
+
return {"up_next": data["up_next"]}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def change_up_next(ctx, org_flag, topic, verb, position=None):
|
|
280
|
+
client = open_client(ctx, org_flag)
|
|
281
|
+
data = read_queue(client)
|
|
282
|
+
row = resolve_topic(data, topic)
|
|
283
|
+
ctx.meta["result_subject"] = row["topic"]
|
|
284
|
+
confirm(ctx, client, f"{verb.capitalize()} \"{row['topic']}\" {'in' if verb == 'move' else 'to' if verb == 'add' else 'from'} Up next?")
|
|
285
|
+
if verb == "move":
|
|
286
|
+
ordered = [entry['id'] for entry in data['up_next']]
|
|
287
|
+
if row['id'] not in ordered or position > len(ordered):
|
|
288
|
+
raise CliError(ExitCode.USAGE, "invalid_position", "Choose a topic and position shown by recala topics up-next list.")
|
|
289
|
+
ordered.remove(row['id'])
|
|
290
|
+
ordered.insert(position - 1, row['id'])
|
|
291
|
+
return client.call(UP_NEXT_REORDER, json={"ordered_ids": ordered})
|
|
292
|
+
return client.call(UP_NEXT_ADD if verb == "add" else UP_NEXT_REMOVE, json={"content_queue_id": row['id']})
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@up_next.command("add")
|
|
296
|
+
@common_options
|
|
297
|
+
@command_runner(contextual_human=action_sentence("Added to Up next"))
|
|
298
|
+
@click.argument("topic")
|
|
299
|
+
@accepted_yes_option
|
|
300
|
+
@dry_run_option
|
|
301
|
+
def add_up_next(ctx, org_flag, topic):
|
|
302
|
+
"""Add an existing topic by id or exact title to Up next."""
|
|
303
|
+
return change_up_next(ctx, org_flag, topic, "add")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@up_next.command("remove")
|
|
307
|
+
@common_options
|
|
308
|
+
@command_runner(contextual_human=action_sentence("Removed from Up next"))
|
|
309
|
+
@click.argument("topic")
|
|
310
|
+
@accepted_yes_option
|
|
311
|
+
@dry_run_option
|
|
312
|
+
def remove_up_next(ctx, org_flag, topic):
|
|
313
|
+
"""Remove a topic from Up next.
|
|
314
|
+
|
|
315
|
+
By id or exact title; the API may refill it."""
|
|
316
|
+
return change_up_next(ctx, org_flag, topic, "remove")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@up_next.command("move")
|
|
320
|
+
@common_options
|
|
321
|
+
@command_runner(contextual_human=action_sentence("Moved in Up next"))
|
|
322
|
+
@click.argument("topic")
|
|
323
|
+
@click.option("--position", type=click.IntRange(1), required=True, help="New position, starting at 1.")
|
|
324
|
+
@accepted_yes_option
|
|
325
|
+
@dry_run_option
|
|
326
|
+
def move_up_next(ctx, org_flag, topic, position):
|
|
327
|
+
"""Move a topic within the existing Up next order."""
|
|
328
|
+
return change_up_next(ctx, org_flag, topic, "move", position)
|