Files
chemenu/tools/chemenu/commands/export_cmd.py
T
torbenandClaude Code 99945c4841
CI / verify (push) Successful in 9m2s
CI / pwsh (push) Successful in 2m48s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped
export: export run writes selected kb/ pages as a derived, read-only version into configured target folders (markdown and command handlers, manifest, after_publish), raw accept also refuses manifest-listed files, doctor checks the section (#214); 10.0.0-beta.13
Files changed:
- CHANGES.md
- VERSION
- docs/how-to/README.md
- docs/how-to/back-up-to-a-synced-folder.md
- docs/how-to/collect-from-a-synced-folder.md
- docs/how-to/export-guidelines.md
- docs/how-to/export-to-a-synced-folder.md
- docs/reference/commands/README.md
- docs/reference/commands/exports.md
- docs/reference/commands/git.md
- docs/reference/commands/guideline-export.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/setup-instance.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/backup.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/raw/_common.py
- tools/chemenu/commands/raw/accept.py
- tools/chemenu/commands/raw/capture.py
- tools/chemenu/config_shapes.py
- tools/chemenu/guideline_export.py
- tools/chemenu/page_export.py
- tools/chemenu/reference_doc.py
- tools/chemenu/repo_capture.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_export_run.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-13 05:53:48 +02:00

556 lines
27 KiB
Python

"""`wikitool export guidelines` - the instance's guideline pages as one
generated `GUIDELINES.md`, printed, or written into the captured repositories
behind the Guideline Push Gate - and `wikitool export run`, the derived reading
copy of `kb/` in the folders the `export` section configures
(`chemenu/page_export.py`; no gate, since a target is a folder the user named).
The core - selection, rendering, the header - is `chemenu/guideline_export.py`;
the git write is `repo_capture.commit_file`/`push_commit`. This module decides
which captured repositories are targets, which of them take part, and when the
gate opens.
**Targets are the captured repositories, and nothing else.** The `repo` and
`ref` of every `_capture.json` under `raw/` - the one declaration that this
instance follows a repository, now read in both directions. A second list of
targets would be a second declaration of the same thing to drift.
**A repository takes part by carrying the file.** Only a branch whose root
`GUIDELINES.md` already starts with the guideline export header is written to;
a hand-written `GUIDELINES.md` is never replaced, and one whose header names a
different `instance=` belongs to that instance. A bare header line is the
opt-in.
**Why a gate, and why this shape.** A push into another repository is the most
irreversible thing the stack does, and a public target publishes the content -
the `instance=` URL included - the moment it lands. So it is held to the same
token form as the Mass-Update and Upload Review gates: the first run fetches,
checks and builds everything, pushes nothing and exits 42 with every target's
status and diff; the token digests exactly that set (URL, branch, old tip, new
blob), so a moved branch, a changed guideline or a new target asks again. The
Publish-Remote Gate is deliberately not applied: the targets are declared by
committed manifests already, and listing the same URLs in
`remotes.allowed_push_urls` would be the second declaration again.
"""
from __future__ import annotations
import difflib
import hashlib
import json
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import typer
from rich.markup import escape
from chemenu import cli_contract, config, guideline_export, page_export, repo_capture
from chemenu.commands._util import EXIT_NEEDS_CLEARANCE, console, fail, rel_path
from chemenu.errors import BackendError, ValidationError
from chemenu.search import filters
app = typer.Typer(help="Generate files from the wiki for other repositories and folders.")
GATE = "guideline-push"
@dataclass
class Target:
"""One `(repo, ref)` pair - several bundles captured from one branch are
one target. `error` is set when a manifest could not be read."""
repo: str
ref: str
bundles: list[str] = field(default_factory=list)
error: str = ""
@property
def label(self) -> str:
return f"{self.repo} {self.ref}"
@dataclass
class Plan:
target: Target
status: str # new | changed | unchanged | skipped
reason: str = ""
tip: str = ""
blob: str = ""
old: bytes = b""
@property
def writes(self) -> bool:
return self.status in ("new", "changed")
def _targets(only: list[Path]) -> list[Target]:
manifests = repo_capture.captured_manifests(config.RAW_DIR)
if only:
wanted = {p.resolve() for p in only}
known = {m.parent.resolve() for m in manifests}
unknown = sorted(rel_path(p) for p in only if p.resolve() not in known)
if unknown:
raise ValidationError(
f"--bundle {', '.join(unknown)}: not a captured bundle under raw/ (a directory "
f"holding {repo_capture.MANIFEST_NAME})."
)
manifests = [m for m in manifests if m.parent.resolve() in wanted]
targets: dict[tuple[str, str], Target] = {}
broken: list[Target] = []
for path in manifests:
bundle = rel_path(path.parent)
try:
manifest = repo_capture.read_manifest(path)
except ValidationError as exc:
broken.append(Target(bundle, "?", [bundle], f"manifest unreadable: {exc}"))
continue
target = targets.setdefault((manifest.repo, manifest.ref), Target(manifest.repo, manifest.ref))
target.bundles.append(bundle)
return [*targets.values(), *broken]
def _plan(target: Target, export: guideline_export.Export) -> Plan:
"""Where this target stands - fetched, never written to."""
if target.error:
return Plan(target, "skipped", target.error)
if repo_capture.is_tag_pattern(target.ref):
return Plan(target, "skipped", "captured by a tag pattern - a tag takes no commit")
try:
with repo_capture.cache_repo(target.repo) as repo:
resolved = repo_capture.resolve_ref(target.repo, target.ref, repo)
tip = repo_capture.fetch(target.repo, resolved.refname, repo)
existing = repo_capture.read_path(repo, tip, guideline_export.TARGET_PATH)
blob = repo_capture.blob_id(repo, export.data)
except BackendError as exc:
return Plan(target, "skipped", f"not reachable: {exc}")
except ValidationError as exc: # the branch does not exist
return Plan(target, "skipped", str(exc))
if existing is None:
return Plan(target, "skipped", f"no {guideline_export.TARGET_PATH} - not opted in", tip)
fields = guideline_export.guidelines_header(existing)
if fields is None:
return Plan(
target, "skipped",
f"{guideline_export.TARGET_PATH} has no export header - hand-written, never replaced", tip,
)
owner = fields.get("instance")
if owner is not None and owner != export.instance:
return Plan(target, "skipped", f"delivered by another instance ({owner})", tip)
if existing == export.data:
return Plan(target, "unchanged", tip=tip)
return Plan(target, "changed" if owner else "new", tip=tip, blob=blob, old=existing)
def confirm_token(plans: list[Plan]) -> str:
"""sha256 over every target the run would write - URL, branch, the tip it
builds on and the blob it writes - cut to 12 hex chars, the shape of
`git_publish.changeset_token`. The new commit's own id is not part of it:
`commit-tree` stamps the time, so it differs on every run."""
payload = json.dumps(
sorted([p.target.repo, p.target.ref, p.tip, p.blob] for p in plans if p.writes),
sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
def _rerun(token: str, predicates: list[str], bundles: list[Path]) -> str:
parts = ["tools/wikitool", "export", "guidelines", "--confirm", token, "--push"]
for raw in predicates:
parts += ["--field", raw]
for bundle in bundles:
parts += ["--bundle", rel_path(bundle)]
return " ".join(shlex.quote(part) for part in parts)
def _diff(plan: Plan, export: guideline_export.Export) -> str:
old = plan.old.decode("utf-8", "replace").splitlines(keepends=True)
new = export.text.splitlines(keepends=True)
name = guideline_export.TARGET_PATH
return "".join(difflib.unified_diff(old, new, f"a/{name}", f"b/{name}"))
def _status_line(plan: Plan) -> str:
detail = f" ({plan.reason})" if plan.reason else ""
return f" {plan.target.label}: {plan.status}{detail}"
def _clearance(plans: list[Plan], export, token: str, rerun: str, stale: Optional[str]) -> str:
writes = [p for p in plans if p.writes]
lines = [
f"This run would push one commit changing only {guideline_export.TARGET_PATH} into "
f"{len(writes)} repositor{'y' if len(writes) == 1 else 'ies'}. A push there is visible at "
"once - publicly, for a public repository - and not cheaply reversible. Nothing was pushed.",
"",
]
if stale:
lines += [
f"The token you passed ({stale}) does not match this set - a branch moved, a guideline "
"changed, a target came or went, or the token was invented. Here is the current state.",
"",
]
lines += [
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.",
"Reproduce every target line and every diff below in your reply, and stop there. Run no "
"further commands in this turn.",
"",
f"TARGETS ({len(plans)}) - reproduce this in your reply:",
"",
*(_status_line(p) for p in plans),
]
for plan in writes:
lines += ["", f"DIFF {plan.target.label}:", "", _diff(plan, export).rstrip("\n")]
lines += [
"",
"Once they have replied approving exactly this, the line that pushes it is:",
"",
f" {rerun}",
"",
f"The token {token} covers each target's branch tip and the exact file; if either moves, "
"clearance is asked again.",
]
return "\n".join(lines)
def _write(plan: Plan, export: guideline_export.Export, identity: tuple[str, str]) -> tuple[str, bool]:
"""Commit and push one target. Returns its result line and whether it
counts as a failure (rejected, or a git error)."""
message = f"Update {guideline_export.TARGET_PATH} from {export.instance} at {export.commit[:12]}"
label = plan.target.label
try:
with repo_capture.cache_repo(plan.target.repo) as repo:
commit = repo_capture.commit_file(
repo, plan.tip, guideline_export.TARGET_PATH, export.data, message, identity
)
result = repo_capture.push_commit(plan.target.repo, repo, commit, plan.target.ref)
except BackendError as exc:
return f" {label}: failed ({exc})", True
if not result.accepted:
return f" {label}: rejected ({result.detail}) - the branch moved; run again", True
return f" {label}: written {plan.tip[:12]} -> {commit[:12]}", False
@app.command("guidelines")
@cli_contract.record(cli_contract.CommandRecord(
path="export guidelines",
summary="Render the guideline pages into one generated `GUIDELINES.md`, or push it into the "
"captured repositories that opted in (**Guideline Push Gate**).",
synopsis=(
cli_contract.Variant(
usage="export guidelines [--field <predicate> ...] [--kind/--subtype/--collection/"
"--tag <v>]",
notes="Prints the file to stdout; no network.",
),
cli_contract.Variant(
usage="export guidelines <predicates> --push [--bundle <raw-bundle> ...] "
"[--confirm TOKEN]",
notes="Writes it into the captured repositories, behind the gate.",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Per target - one commit pushed without force, or nothing; targets already "
"written stay written when a later one fails",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
gates=(GATE,),
),
notes=(
"Selects pages with `search`'s own predicates and no text: `--field` (repeatable, AND), "
"`--kind`, `--subtype`, `--collection`, `--tag`. Which filter selects this instance's "
"guidelines is written down in `kb/CONVENTIONS.md`.",
"Renders mechanically: line 1 is `<!-- wikitool:export kind=guidelines instance=<origin "
"URL without userinfo, or local> commit=<newest commit touching a selected page> - "
"generated, do not edit by hand -->`; then each page sorted by title, as `# <title>` and "
"its text without frontmatter, the generated links and footnote blocks, citation markers "
"(`[^id]`, legacy `^[[...]]`), with `[[Title|Text]]` as `Text` and `[[Title]]` as "
"`Title`. A leading H1 of the page is replaced by the title heading. Code is never "
"rewritten. LF endings, no timestamp: the same pages give the same bytes.",
"Refuses before printing or pushing anything: no predicate, no match, an unknown field, "
"any page under `kb/` with unreadable frontmatter, uncommitted changes in `kb/` or "
"`types/` (untracked files included), or no git checkout.",
"`--push` targets every captured repository - each `(repo, ref)` from a `_capture.json` "
"under `raw/`, `--bundle` narrowing it. A target is skipped with its reason when its ref "
"rule is a tag pattern, it cannot be reached or has no such branch, its root has no "
"`GUIDELINES.md` (not opted in), its `GUIDELINES.md` has no export header (hand-written), "
"or the header names another `instance=`. A header line alone is the opt-in.",
"Guideline Push Gate: `--push` without a matching `--confirm` fetches and builds "
"everything, pushes nothing, and exits 42 with each target's status (`new`, `changed`, "
"`unchanged`, `skipped`), the diff for every target it would write, and the re-run line. "
"The token digests URL, branch, old tip and new blob of every target to write.",
"With a matching token, each target gets exactly one commit on the fetched tip changing "
"only `GUIDELINES.md`, authored as the instance checkout's `user.name`/`user.email`, "
"pushed without force; one line per target: `unchanged`, `written <old> -> <new>`, "
"`skipped (<reason>)`, `rejected (<reason>)` or `failed (<reason>)`.",
"Nothing to write (every target unchanged or skipped) ends with exit 0 and no gate. A "
"second run after a successful one finds every target unchanged.",
"The target repository's own `AGENTS.md` points at the file (Claude Code: "
"`@GUIDELINES.md`); that belongs to the repository, not to this command.",
),
failures=(
cli_contract.Failure(
cause="No predicate, no page matches, a malformed predicate or an unknown field",
reaction="Use the filter `kb/CONVENTIONS.md` names for this instance's guidelines, and "
"retry once",
),
cli_contract.Failure(
cause="A page under `kb/` has unreadable frontmatter, `kb/` or `types/` has "
"uncommitted changes, or the instance is not a git checkout",
reaction="Fix the named pages, or publish the changes first, then retry once",
),
cli_contract.Failure(
cause="`--push` with no `user.name` or `user.email` in the instance checkout's git "
"configuration - checked before any target is fetched",
reaction="Show the message to the user; the identity is theirs to set, never yours",
),
cli_contract.Failure(
cause="`--bundle` names no captured bundle under `raw/`, or `--bundle`/`--confirm` "
"was given without `--push`",
reaction="Fix the argument and retry once",
),
cli_contract.Failure(
cause="A target rejected the push (its branch moved since the fetch) or a git call "
"failed - the other targets were still served",
reaction="Show the lines to the user. A rejected target is served by running the "
"whole command again, which asks for clearance again",
),
cli_contract.Failure(
code=0,
cause="A target was skipped - tag rule, not reachable, not opted in, hand-written "
"file, another instance's file",
reaction="",
),
cli_contract.Failure(
cause="Guideline Push Gate: `--push` without `--confirm`, or with a token that does "
"not match the current set of targets",
reaction=cli_contract.token_gate_reaction("--confirm"),
code=42,
),
),
examples=(
"tools/wikitool export guidelines --tag guideline",
"tools/wikitool export guidelines --tag guideline --push",
"tools/wikitool export guidelines --confirm <token> --push --field tags=guideline # "
"re-run after exit 42, once the user approved every diff",
),
never=(
"Never pass a `--confirm` token the user has not seen and approved.",
"Never edit a `GUIDELINES.md` in a target repository by hand to make it match - the "
"next export overwrites it; change the guideline page instead.",
),
see_also=(
"`wikitool search` - the same predicates, to preview the selection",
"`wikitool raw capture` - how a repository becomes a target",
"`instructions/gates.md` - the gate procedure",
),
))
def export_guidelines_command(
field_: Optional[list[str]] = typer.Option(
None, "--field", "-f",
help="Frontmatter predicate, repeatable (AND) - the same forms as `search --field`.",
),
kind: Optional[str] = typer.Option(None, "--kind", help="Shorthand for --field kind=<value>."),
subtype: Optional[str] = typer.Option(None, "--subtype", help="Shorthand for --field subtype=<value>."),
collection: Optional[str] = typer.Option(
None, "--collection", help="Shorthand for --field collection=<value>."
),
tag: Optional[str] = typer.Option(None, "--tag", help="Shorthand for --field tags=<value>."),
push: bool = typer.Option(
False, "--push", help="Write the file into the captured repositories (Guideline Push Gate)."
),
bundle: Optional[list[Path]] = typer.Option(
None, "--bundle", help="Only the repository of this captured bundle under raw/; repeatable."
),
confirm: Optional[str] = typer.Option(
None, "--confirm", help="The token from a prior exit 42, once a human has approved it."
),
):
"""Render the guideline pages into GUIDELINES.md - printed, or pushed into
the captured repositories that carry one."""
bundles = [b if b.is_absolute() else config.ROOT / b for b in (bundle or [])]
if not push and (bundles or confirm):
fail("--bundle and --confirm only apply with --push.")
raw = filters.raw_predicates(field_, kind, subtype, collection, tag)
try:
predicates = tuple(filters.parse_predicate(r) for r in raw)
export = guideline_export.build(predicates)
except ValidationError as exc:
fail(escape(str(exc)))
return
if not push:
typer.echo(export.text, nl=False)
return
identity = guideline_export.git_identity()
if identity is None:
fail(
"No git identity in this instance checkout - `git config user.name` and `git config "
"user.email` must both be set, since the commit in each target repository carries them. "
"Nothing was fetched or pushed."
)
return
try:
targets = _targets(bundles)
except ValidationError as exc:
fail(escape(str(exc)))
return
if not targets:
typer.echo("No captured repository under raw/ - nothing to write.")
return
plans = [_plan(t, export) for t in targets]
if not any(p.writes for p in plans):
typer.echo(f"{len(plans)} target(s), nothing to write:")
for plan in plans:
typer.echo(_status_line(plan))
return
token = confirm_token(plans)
if confirm != token:
console.print("[bold yellow]NEEDS USER CLEARANCE[/bold yellow] Guideline Push Gate")
typer.echo(_clearance(plans, export, token, _rerun(token, raw, bundles), confirm))
raise typer.Exit(code=EXIT_NEEDS_CLEARANCE)
failed = 0
typer.echo(f"{len(plans)} target(s):")
for plan in plans:
if not plan.writes:
typer.echo(_status_line(plan))
continue
line, bad = _write(plan, export, identity)
failed += bad
typer.echo(line)
written = sum(1 for p in plans if p.writes) - failed
typer.echo(f"{written} written, {failed} rejected or failed, {len(plans) - written - failed} "
"unchanged or skipped.")
if failed:
console.print(
f"[bold red]ERROR[/bold red] {failed} target(s) not written - what was written stays "
"written; run the command again for the rest."
)
raise typer.Exit(code=1)
@app.command("run")
@cli_contract.record(cli_contract.CommandRecord(
path="export run",
summary="Write the derived, read-only reading copy of `kb/` into the folders the `export` "
"section configures - the selected pages as Markdown, or converted by a program of this "
"checkout.",
synopsis=(
cli_contract.Variant(
usage="export run [<target> ...]",
notes="Every configured target, or the named ones. Needs an `export` section in "
f"`{config.LOCAL_SETTINGS_FILENAME}`; the user writes it by hand.",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Per file within a target (a temporary file in the target, then a move over its "
"place), the manifest last; targets are independent, so a failed target does not undo "
"another",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"An export is derived: regenerated from `kb/` on every run, never edited, never read back "
"into the wiki. Each target names its pages with an allowlist of `search --field` "
"predicates (`include`: a list of selections, AND within one, OR across them), a handler "
"(`markdown`, or `command` for the operator's converter) and the folder (`path`, outside "
"the instance). Nothing is exported that no selection names.",
"Every Markdown file starts with a frontmatter block whose `wikitool_export` line says "
"`kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit "
"touching a selected page>`, followed by `title`, `type`, `summary`, `modified` where the "
"page has them (`frontmatter: summary`, the default; `minimal` keeps only the marker, "
"`full` the whole frontmatter), then the page text. A file keeps its path below `kb/`. A "
"wikilink to an exported page becomes a relative Markdown link (`links: relative`, the "
"default) or plain text (`links: text`); a link to a page that is not exported is always "
"plain text. Footnote markers stay; code is never rewritten. LF endings, no timestamp: "
"the same pages give the same bytes.",
"Handler `command`: the Markdown is rendered into a staging directory, the program of "
"`export.commands` runs with no shell, stdin closed and a timeout of 300 s, and receives "
"the Markdown directory and an empty output directory as its last two arguments. On exit "
"0 the regular files in the output directory (recursively) become the target's content; "
"any other end leaves the target unchanged and prints the end of its stderr. The staging "
"directory is always removed.",
"The folder is only ever touched by what it holds in its manifest, `.wikitool-export.json`: "
"the files an earlier run wrote, each with its sha256. A run removes only manifest files "
"that are no longer exported (and folders it created, once empty), rewrites a manifest "
"file that is missing, leaves every other file byte for byte as it was, and writes "
"nothing - not even a modification time - for a file that did not change. A run that "
"changes nothing writes nothing at all.",
"Refuses a target, before writing anything into it: a non-empty folder with no manifest "
"(it is somebody's, not an export), a manifest file that was changed in the folder since "
"the last run (naming it), a foreign file where an exported one belongs, an empty "
"selection (it would delete the earlier export), an unknown field in a predicate. A "
"missing folder is created.",
"Refuses everything, before any target: uncommitted changes in `kb/` or `types/` "
"(untracked files included), no git checkout, a page under `kb/` with unreadable "
"frontmatter, an unknown target name, a broken `export` section.",
"Output per target: `new` (with the page titles), `changed`, `removed`, the count "
"`unchanged`, or `failed` with the reason. A page that newly enters a selection is "
"reported as new, not gated. Exit 0 when every target ran; exit 1 when any failed - the "
"others were still served.",
"A target with `after_publish: true` is also refreshed by every `publish` that "
"committed, `--no-push` included; a failure there is a warning, the commit stands, and "
"this command is the repeat.",
"`raw accept` refuses a file whose sha256 a target's manifest lists, and one that carries "
"the marker: an export never goes back into `raw/`.",
),
failures=(
cli_contract.Failure(
cause="No `export` section, an unknown target name, or a broken section (a predicate "
"that does not parse, a path inside the instance or in the way of another target, an "
"`intake` folder or the backup target)",
reaction="Show the message to the user; the section is theirs to write by hand, never "
"yours",
),
cli_contract.Failure(
cause="`kb/` or `types/` has uncommitted changes, the instance is not a git checkout, "
"or a page under `kb/` has unreadable frontmatter",
reaction="Fix the named pages, or publish the changes first, then retry once",
),
cli_contract.Failure(
cause="A target failed: a foreign folder, a file changed in the target, an empty "
"selection, an unknown field, a converter that did not exit 0",
reaction="Show the target's reason to the user; the other targets were still served. "
"Never delete or edit files in a target folder to make a run pass",
),
),
examples=(
"tools/wikitool export run",
"tools/wikitool export run work-notes",
),
never=(
"Never edit a file in a target folder to change an export - the next run refuses or "
"overwrites it; change the page, or the target's `include`, instead.",
"Never put an export folder into `incoming/` or `raw/`.",
),
see_also=(
"`wikitool search` - the same predicates, to preview a selection",
"`wikitool backup run` - the same kind of folder, for this machine's own files",
"`docs/how-to/export-to-a-synced-folder.md`",
),
))
def export_run_command(
names: Optional[list[str]] = typer.Argument(
None, metavar="[TARGET]...", help="Targets to run; default: every configured target."
),
):
"""Write the reading copy of kb/ into the configured export folders."""
try:
targets = page_export.choose(page_export.require_config(), names or [])
results = page_export.run(targets)
except ValidationError as exc:
fail(escape(str(exc)))
return
failed = 0
for result in results:
for line in page_export.report_lines(result):
typer.echo(line)
failed += not result.ok
if failed:
console.print(f"[bold red]ERROR[/bold red] {failed} of {len(results)} target(s) failed - the "
"others were served; fix the reason above and run again.")
raise typer.Exit(code=1)