recala-cli 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. recala_cli/__init__.py +3 -0
  2. recala_cli/__main__.py +3 -0
  3. recala_cli/article_runs.py +372 -0
  4. recala_cli/auth.py +196 -0
  5. recala_cli/client.py +103 -0
  6. recala_cli/commands/__init__.py +1 -0
  7. recala_cli/commands/article.py +835 -0
  8. recala_cli/commands/auth.py +207 -0
  9. recala_cli/commands/billing.py +76 -0
  10. recala_cli/commands/bing.py +164 -0
  11. recala_cli/commands/cadence.py +217 -0
  12. recala_cli/commands/connect.py +395 -0
  13. recala_cli/commands/credits.py +46 -0
  14. recala_cli/commands/faithful_translations.py +70 -0
  15. recala_cli/commands/gsc.py +79 -0
  16. recala_cli/commands/index.py +293 -0
  17. recala_cli/commands/keys.py +98 -0
  18. recala_cli/commands/knowledge.py +245 -0
  19. recala_cli/commands/languages.py +119 -0
  20. recala_cli/commands/monthly_report.py +152 -0
  21. recala_cli/commands/notifications.py +106 -0
  22. recala_cli/commands/onboard.py +237 -0
  23. recala_cli/commands/org.py +352 -0
  24. recala_cli/commands/profile.py +70 -0
  25. recala_cli/commands/questions.py +246 -0
  26. recala_cli/commands/reads.py +320 -0
  27. recala_cli/commands/refresh.py +138 -0
  28. recala_cli/commands/refusals.py +20 -0
  29. recala_cli/commands/schema.py +47 -0
  30. recala_cli/commands/shell.py +81 -0
  31. recala_cli/commands/skills.py +92 -0
  32. recala_cli/commands/slot_topic.py +94 -0
  33. recala_cli/commands/status.py +153 -0
  34. recala_cli/commands/topics.py +328 -0
  35. recala_cli/config.py +176 -0
  36. recala_cli/confirm.py +137 -0
  37. recala_cli/display.py +40 -0
  38. recala_cli/endpoints.py +258 -0
  39. recala_cli/errors.py +220 -0
  40. recala_cli/human.py +324 -0
  41. recala_cli/ids.py +139 -0
  42. recala_cli/labels.py +74 -0
  43. recala_cli/main.py +129 -0
  44. recala_cli/onboarding.py +522 -0
  45. recala_cli/organizations.py +197 -0
  46. recala_cli/output.py +34 -0
  47. recala_cli/parallel.py +18 -0
  48. recala_cli/polling.py +102 -0
  49. recala_cli/prompts.py +88 -0
  50. recala_cli/runtime.py +216 -0
  51. recala_cli/search_numbers.py +22 -0
  52. recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
  53. recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
  54. recala_cli/skills/recala-cli-contract.md +78 -0
  55. recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
  56. recala_cli/skills/recala-onboard/SKILL.md +25 -0
  57. recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
  58. recala_cli/skills/recala-plan-month/SKILL.md +33 -0
  59. recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
  60. recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
  61. recala_cli/ui.py +260 -0
  62. recala_cli/updates.py +43 -0
  63. recala_cli/views.py +372 -0
  64. recala_cli-0.6.0.dist-info/METADATA +63 -0
  65. recala_cli-0.6.0.dist-info/RECORD +67 -0
  66. recala_cli-0.6.0.dist-info/WHEEL +4 -0
  67. recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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)