#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6"]
# ///
"""fkb - the federated knowledge bundles a machine can see.

`list` answers "which bundles exist and what may I do with each", which has to be
answered before anything is written. `lint` answers "does this bundle hold up",
by calling the same checker a bundle's own pre-commit hook calls. `url` answers
"how do I link to a concept over there", which is the one question neither bundle
can answer alone. `resolve` answers "what does this bundle actually do", as
opposed to what its manifest entry declares. `sync` answers "may these commits
leave this machine right now", which is the only question in here whose wrong
answer tramples somebody's work rather than misinforming them.

`version` and `migrate` answer the two halves of "is this setup the one this
skill was written for": what the installed copy is, and what the manifest on this
machine has been brought up to. They are here rather than left to a release note
because the skill is installed by copying, so an upgrade arrives with no
announcement and nothing on the machine notices it happened.

`search` is still withheld while the journal records whether the read path needs
it, so that what gets built next answers an incident rather than a guess.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import textwrap
from collections import Counter
from datetime import date
from pathlib import Path

import yaml

sys.path.insert(0, str(Path(__file__).resolve().parent))

import arrival
import bundle_lint
import sharing
import versions
import workspace
from workspace import Bundle, load_bundles


def version_notice() -> list[str]:
    """What to say, if anything, about the distance between this skill and this setup.

    Three states and three different readers. Level is the ordinary one and says
    nothing: a line printed on every invocation is a line nobody reads on the one
    invocation it matters.

    Behind, with guides to show, is the case this exists for, and it names the
    files rather than describing them - an agent that has been told a migration
    is due and not told where to read about it will invent the difference.

    Ahead is not a fault and is common: one manifest, several harnesses, a skill
    copied into each, one of them upgraded. Saying so plainly beats the confusion
    of a stale copy behaving like an old one for no visible reason. It is a
    remark, never a refusal - a second install being out of date must not stop
    anybody reading their own bundles.

    Nothing here blocks. A version stamp is bookkeeping about capabilities, and
    the bundles were readable before this field existed.
    """
    here = versions.skill_version()
    there = versions.manifest_version(workspace.read_manifest())
    if there == here:
        return []
    if there > here:
        return [
            f"This workspace was last migrated by fkb {versions.render(there)}; "
            f"this skill is {versions.render(here)}.",
            f"Update the copy at {versions.SKILL_ROOT} - another harness on this machine has a newer one.",
        ]
    due = versions.guides(there, here)
    if not due:
        return []
    known = "predates the version field" if there == versions.BASELINE else versions.render(there)
    return [
        f"This workspace {known}; this skill is {versions.render(here)}, and "
        f"{'one change asks' if len(due) == 1 else f'{len(due)} changes ask'} something of it.",
        *(f"  read {guide}" for guide in due),
        f"Then `{workspace.invocation()} migrate --done` to record it.",
    ]


def cmd_version(_: argparse.Namespace) -> int:
    """Both versions and the distance between them, for when something looks wrong.

    Prints the two numbers even when they agree, which is the difference between
    this and the notice `list` carries: that one speaks when there is something
    to do, this one is what you run when you want to know.
    """
    print(f"fkb {versions.render(versions.skill_version())}")
    print(f"  installed at  {versions.SKILL_ROOT}")
    path = workspace.manifest_path()
    if not path.is_file():
        print(f"  workspace     none at {path}")
        return 0
    stamped = versions.manifest_version(workspace.read_manifest())
    seen = "unstamped (predates the version field)" if stamped == versions.BASELINE else versions.render(stamped)
    print(f"  workspace     {seen}, at {path}")
    for line in version_notice():
        print(line)
    return 0


def cmd_migrate(args: argparse.Namespace) -> int:
    """Name the guides this setup still owes, or record that it owes none.

    Reading and doing are two commands' worth of consequence behind one name, so
    they are split by a flag that has to be typed: bare `migrate` shows the work,
    `--done` says it happened. The stamp is never a side effect of showing,
    because the work in a guide is a conversation with a person - a decision
    about what their bundles should now do - and no command here can observe
    whether that conversation went well.

    Declining a guide's offer still completes it. The question was put and
    answered, and leaving the stamp behind would put it again next week.
    """
    path = workspace.manifest_path()
    me = workspace.invocation()
    if not path.is_file():
        sys.exit(f"fkb: no workspace manifest at {path}. Nothing to migrate; `{me} init` starts one.")

    here = versions.skill_version()
    there = versions.manifest_version(workspace.read_manifest())
    if there > here:
        print(f"This workspace stands at {versions.render(there)}, ahead of this skill's {versions.render(here)}.")
        print(f"Nothing to do here. Update the copy at {versions.SKILL_ROOT} to catch up.")
        return 0

    due = versions.guides(there, here)
    if args.done:
        versions.stamp(path, here)
        print(f"{path} now records fkb {versions.render(here)}.")
        return 0
    if not due:
        print(f"Nothing to migrate: this skill is {versions.render(here)} and asks nothing of this workspace.")
        if there != here:
            print(f"Record that with `{me} migrate --done`.")
        return 0

    print(f"fkb {versions.render(here)}, workspace at {versions.render(there) if there != versions.BASELINE else 'no recorded version'}.")
    print("Read these in order and do what each says, deciding with the person, not for them:\n")
    for guide in due:
        print(f"  {guide}")
    print(f"\nThen record it: `{me} migrate --done`. A change they decline is still migrated.")
    return 0


def cmd_list(_: argparse.Namespace) -> int:
    """Print each bundle under the manifest's own field names.

    Inventing labels here would give the same policy two vocabularies, and an
    agent reading `open` has no way back to the line that produced it. `config`
    and `conventions` are the two rows naming no manifest field, because one is
    discovered in the bundle rather than declared about it, and the other is
    declared by the bundle itself.

    The version notice comes first and is printed in both branches, including the
    empty one. `list` is the command the skill reaches for before anything else,
    which makes it the only place a notice is certain to be read - and a
    workspace with no bundles in it yet is exactly the setup most likely to be
    standing on an old release.
    """
    notice = version_notice()
    if notice:
        print("\n".join(notice) + "\n")
    bundles = load_bundles(allow_empty=True)
    if not bundles:
        print("No bundles yet.")
        print(f"The workspace exists at {workspace.manifest_path()} and holds nothing.")
        print(f"\nBring one in with `{workspace.invocation()} add <name>`, by one of three routes:")
        print("  --clone <remote>  a bundle in a git repo somewhere")
        print("  --path <dir>      a checkout already on this machine")
        print("  --new             a bundle that does not exist yet")
        return 0
    # Named here because where it sits is a fact about the machine - XDG when
    # that is set, the user config directory otherwise - so no page can state it
    # and be right everywhere. It is also the file to hand-edit to change policy.
    print(f"workspace  {workspace.manifest_path()}\n")
    for bundle in bundles:
        missing = "" if bundle.path.is_dir() else "  (MISSING)"
        print(f"{bundle.name}{missing}")
        print(f"  path              {bundle.path}")
        print(f"  referenceable_by  {bundle.citation}")
        print(f"  writable          {'true' if bundle.writable else 'false'}")
        if bundle.sync is None:
            print("  sync              null (not shared: commits stay on this machine)")
        else:
            on = sharing.checked_out_branch(bundle.path)
            drifted = "" if on in (bundle.sync, None) else f"  (checked out on {on})"
            print(f"  sync              {bundle.sync}{drifted}")
        if bundle.publish is None:
            print("  publish           null (no published location: nothing may link here)")
        else:
            print(f"  publish.url       {bundle.publish.url}")
            print(f"  publish.style     {bundle.publish.style}")
        print(f"  config            {bundle.config or 'not declared'}")
        conventions = bundle.conventions
        if conventions is None:
            print("  conventions       not declared")
        else:
            broken = "" if conventions.is_file() else "  (MISSING)"
            print(f"  conventions       {conventions}{broken}")
    return 0


ABSOLUTE_LINK = re.compile(r"\[[^\]]*\]\((https?://[^)\s]+)\)")


def check_references(bundle: Bundle, others: list[Bundle], findings: bundle_lint.Findings) -> None:
    """Apply the reference rule to the absolute links a bundle already carries.

    `fkb url` refuses a forbidden link when an agent asks for one, which covers
    the links this federation writes. This covers the rest: a link pasted by
    hand, one that predates the policy, and one that became a violation because
    `referenceable_by` was tightened afterwards. Same rule, at the two points
    where it can be broken.

    Only URLs that match a known bundle's publish prefix are considered. An
    ordinary external link resembles nothing here and is left alone, which is
    also why §4 requires prefixes not to nest: the attribution below has to have
    one answer.

    Every markdown file counts, not only concepts. The rest of lint reads
    concepts because that is what OKF constrains, but disclosure does not care
    which file did it: an index that links into a sealed bundle has leaked it as
    surely as a concept would, and in this federation the indexes are where the
    cross-bundle links actually are.
    """
    for concept in sorted(p for p in bundle.path.rglob("*.md") if p.is_file()):
        rel = concept.relative_to(bundle.path).as_posix()
        for url in ABSOLUTE_LINK.findall(concept.read_text(encoding="utf-8")):
            for target in others:
                if target.publish is None:
                    continue
                candidates = target.publish.to_paths(url)
                if candidates is None:
                    continue
                if not target.may_be_cited_by(bundle.name):
                    findings.add(
                        rel,
                        f"reference rule: links into `{target.name}`, which does not list "
                        f"`{bundle.name}` in `referenceable_by`",
                        blocks=True,
                    )
                elif not any((target.path / c).is_file() for c in candidates):
                    findings.add(
                        rel,
                        f"cross-bundle link into `{target.name}` resolves to no concept there ({url})",
                        blocks=False,
                    )
                break


def check_shared_branch(bundle: Bundle, findings: bundle_lint.Findings) -> None:
    """Say when a shared bundle's checkout has drifted off the branch it is shared on.

    The cheap half of what `sync` knows, and the half available without touching
    a network. A checkout on another branch still takes commits and still passes
    every other check here, and the only visible symptom is that nothing this
    person files reaches anybody - which is a thing to be told rather than to
    work out.

    A warning rather than a blocking finding: nothing about the knowledge is
    wrong, and a lint that refuses a good concept because a branch is elsewhere
    would be teaching people to file past it.
    """
    if bundle.sync is None:
        return
    on = sharing.checked_out_branch(bundle.path)
    if on is None or on == bundle.sync:
        return
    findings.add(
        "",
        f"this bundle is shared on `{bundle.sync}` and the checkout is on `{on}`, "
        "so nothing filed here is reaching anyone",
        blocks=False,
    )


def cmd_lint(args: argparse.Namespace) -> int:
    bundles = load_bundles()
    selected = bundles
    if args.bundle:
        by_name = {b.name: b for b in bundles}
        if args.bundle not in by_name:
            sys.exit(f"fkb: no bundle named `{args.bundle}` (have: {', '.join(by_name)})")
        selected = [by_name[args.bundle]]

    status = 0
    for bundle in selected:
        print(f"{bundle.name} ({bundle.path})")
        if not bundle.path.is_dir():
            print("  ERROR  the manifest points at a path that does not exist")
            status = 1
            continue
        findings = bundle_lint.lint(bundle.path, floor=bundle.floor, coverage=args.coverage)
        check_references(bundle, [b for b in bundles if b.name != bundle.name], findings)
        check_shared_branch(bundle, findings)

        # An upstream we cannot edit is not a failure state, and a lint that
        # fails on what nobody here can fix is a lint you learn to ignore - along
        # with the one finding in it that mattered.
        if not bundle.writable:
            findings.reported.extend(findings.blocking)
            findings.blocking = []

        for line in findings.reported:
            print(f"  warn   {line}")
        for line in findings.blocking:
            print(f"  ERROR  {line}")
        if findings.blocking:
            print(f"  {len(findings.blocking)} blocking finding(s)")
            status = 1
        else:
            suffix = f" ({len(findings.reported)} warning(s))" if findings.reported else ""
            print(f"  ok{suffix}" + ("  [read-only: nothing here can block]" if not bundle.writable else ""))
    return status


def cmd_url(args: argparse.Namespace) -> int:
    """Print the URL under which one concept in one bundle can be cited from another.

    The command exists rather than `resolve` returning a prefix because the
    alternative hands every caller the prefix and the style and asks it to do the
    concatenation. Two callers doing that is two readings of one field, which is
    a mistake this design has already made once.

    Refusing is as much the point as the URL. An agent that gets a string back
    has been told the link is permitted, so the check and the formatting cannot
    come apart, and a forbidden link fails where it is asked for rather than
    surviving in a file until lint sees it.
    """
    by_name = {b.name: b for b in load_bundles()}
    target = by_name.get(args.bundle)
    if target is None:
        sys.exit(f"fkb: no bundle named `{args.bundle}` (have: {', '.join(by_name)})")
    if args.citing not in by_name:
        sys.exit(f"fkb: no bundle named `{args.citing}` (have: {', '.join(by_name)})")

    if not target.may_be_cited_by(args.citing):
        sys.exit(
            f"fkb: `{args.citing}` may not cite `{args.bundle}`.\n"
            f"`{args.bundle}` lists who may point at it, and `{args.citing}` is not among "
            "them. This is a policy refusal, not a formatting one: if the link is meant to "
            f"exist, `referenceable_by` in `{args.bundle}`'s manifest entry is what decides it."
        )
    if target.publish is None:
        sys.exit(
            f"fkb: `{args.bundle}` has no published location, so there is no URL to cite.\n"
            "A relative path would be a fact about this machine and would not survive "
            "publication or a clone. Give the bundle a `publish:` entry - it may name where "
            "the bundle *will* live, since nothing here fetches it - or do not link to it."
        )

    relative = args.path.lstrip("/")
    if not (target.path / relative).is_file():
        sys.exit(
            f"fkb: `{relative}` is not a file in `{args.bundle}` ({target.path}).\n"
            "Checking this costs a stat now and is impossible later: once the link is a URL "
            "in a committed file, only fetching the site can tell you the concept is there."
        )

    print(target.publish.to_url(relative))
    return 0


def cmd_sync(args: argparse.Namespace) -> int:
    """Move commits to and from the bundles this machine shares with other people.

    Safe at any moment, which is what lets the skill call it without deciding
    anything: run before reading and it fast-forwards, run after filing and it
    pushes. Everything it will not do is in
    [sharing.py](sharing.py) and in DESIGN §10 - it moves commits, it never
    makes one, and it refuses rather than resolving.

    With no bundle named it visits every one that declares a `sync` branch, as
    `lint` does over all of them. A bundle that declares none is printed as
    nothing to do rather than skipped silently: "this bundle is not shared" is
    the answer to the question that was asked.
    """
    bundles = load_bundles()
    if args.bundle:
        by_name = {b.name: b for b in bundles}
        if args.bundle not in by_name:
            sys.exit(f"fkb: no bundle named `{args.bundle}` (have: {', '.join(by_name)})")
        selected = [by_name[args.bundle]]
    else:
        selected = [b for b in bundles if b.sync is not None]
        if not selected:
            print("No bundle in this workspace is shared, so there is nothing to sync.")
            print("A bundle becomes shared by naming the branch it is shared on: `sync: <branch>`.")
            return 0

    status = 0
    for bundle in selected:
        outcome = sharing.sync(bundle.name, bundle.path, bundle.sync, check=args.check)
        print(f"{bundle.name}  {outcome.summary}")
        for line in outcome.details:
            print(f"  {line}")
        if outcome.remedy:
            print(f"  {outcome.remedy}")
        if outcome.blocked:
            status = 1
    return status


def cmd_resolve(args: argparse.Namespace) -> int:
    """One bundle as JSON: what it is allowed to do, and what it actually does.

    The vocabulary half is derived rather than declared, so it cannot drift from
    the files and works on a read-only upstream that will never adopt anyone's
    conventions. Counts are reported with it because a bare list of tags says
    only that a string exists: the tail is where one subject spelled two ways
    shows up, and a tail needs counting to be visible. Noticing that two entries
    name one subject is semantic work and belongs to the skill; this command owes
    it a legible input.
    """
    by_name = {b.name: b for b in load_bundles()}
    bundle = by_name.get(args.bundle)
    if bundle is None:
        sys.exit(f"fkb: no bundle named `{args.bundle}` (have: {', '.join(by_name)})")
    if not bundle.path.is_dir():
        sys.exit(f"fkb: `{args.bundle}` points at {bundle.path}, which does not exist")

    tags: Counter[str] = Counter()
    types: Counter[str] = Counter()
    for concept in bundle_lint.concept_files(bundle.path):
        meta = bundle_lint.frontmatter(concept)
        declared = meta.get("tags")
        if isinstance(declared, str):
            declared = [declared]
        for tag in declared or []:
            if isinstance(tag, str) and tag.strip():
                tags[tag.strip()] += 1
        kind = meta.get("type")
        if isinstance(kind, str) and kind.strip():
            types[kind.strip()] += 1

    report = {
        "name": bundle.name,
        "path": str(bundle.path),
        "referenceable_by": bundle.referenceable_by,
        "writable": bundle.writable,
        "sync": bundle.sync,
        "publish": None if bundle.publish is None else {"url": bundle.publish.url, "style": bundle.publish.style},
        "conventions": None if bundle.conventions is None else str(bundle.conventions),
        "directories": sorted(p.name for p in bundle.path.iterdir() if p.is_dir() and not p.name.startswith(".")),
        # Ordered by frequency, because that is the axis a split shows on, and
        # alphabetically within a count so the output is stable to diff.
        "tags": dict(sorted(tags.items(), key=lambda kv: (-kv[1], kv[0]))),
        "types": dict(sorted(types.items(), key=lambda kv: (-kv[1], kv[0]))),
    }
    print(json.dumps(report, indent=2))
    return 0


def _citers(declared: str | None) -> list[str] | str:
    """Read `--referenceable-by` as the manifest holds it, defaulting to sealed.

    `*` stays the string it is in YAML, so what `list` prints can be found in the
    file it came from. Anything absent means nobody, which is the cautious
    default a bundle added carelessly should get.
    """
    if declared == "*":
        return "*"
    if not declared:
        return []
    return [name.strip() for name in declared.split(",") if name.strip()]


def _proposed_roots() -> list[tuple[Path, str]]:
    """The workspace roots worth proposing on this machine, and why each.

    Ordered, and the order is the recommendation: the hidden one first, because
    a knowledge base is reached through an agent far more often than it is opened
    by hand, and a visible directory in `$HOME` is a cost paid every day by
    everyone for a case that turns up occasionally. Somebody who does mean to
    edit the files says so, and the second line is there for them.

    Absolute rather than `~`-prefixed, because the caller may hand what it reads
    straight back as an argument and a `~` only expands in some shells. This is
    also the only statement of these defaults: `SKILL.md` used to carry its own
    copy and now asks for them instead.

    ATTENTION: both are POSIX-shaped on every operating system. `Path.home()`
    gets the separators right, but "a directory under the agent directory" is a
    Unix habit and may not be where a Windows user would put either of these.
    """
    home = Path.home()
    return [
        (home / ".agents" / "knowledge", "recommended: out of the way, reached through an agent"),
        (home / "knowledge", "you mean to open and edit these files yourself"),
    ]


def cmd_init(args: argparse.Namespace) -> int:
    """Create the workspace manifest, once per machine.

    Separate from `add` because it answers a different question: where bundles
    live on this machine, which has nothing to do with what is in any of them.
    It refuses to overwrite, because a manifest is hand-editable and the one on
    disk is the only record of policy that was decided rather than defaulted.

    The root is asked for rather than defaulted, but the proposals are ordered
    and the first is a recommendation rather than a coin toss. Where bundles live
    is still the person's call - it is their disk - so the command refuses to
    pick silently; what it will do is say which answer is usually right.

    `--propose-roots` is a question rather than an action, which is why it prints
    and exits 0 having written nothing. Overloading the no-argument case with it
    would have been the same thing for free, but then `fkb init` with a forgotten
    `--workspace-root` reports success and leaves no manifest.
    """
    me = workspace.invocation()
    if args.propose_roots:
        print("Workspace roots that make sense on this machine, best answer first:")
        for root, why in _proposed_roots():
            print(f"  {root}    # {why}")
        print(f"\nAny path works. Pass one back as `{me} init --workspace-root <dir>`.")
        print("Relative bundle paths in the manifest resolve under it, and a bundle")
        print("that already lives elsewhere is registered where it lies.")
        return 0

    if not args.workspace_root:
        print("fkb init needs a workspace root: where bundles are checked out on this machine.")
        print(f"Ask for the ones that make sense here: `{me} init --propose-roots`.")
        return 1

    path = workspace.manifest_path()
    if path.exists():
        sys.exit(
            f"fkb: {path} already exists. Bundles go in it with `{me} add`; "
            "edit the file directly to change policy."
        )
    root = Path(args.workspace_root).expanduser()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        "# The federation, as this machine sees it.\n"
        "#\n"
        "# `path` is where a bundle's concepts start, which is not always the\n"
        "# repository root: a bundle that publishes usually keeps them under\n"
        "# `docs/`. Relative paths resolve under `workspace_root`.\n"
        "#\n"
        "# `version` is the fkb release this file was last brought up to. It is\n"
        "# written by `fkb migrate --done` and is not yours to edit.\n"
        f"version: {versions.render(versions.skill_version())}\n"
        f"workspace_root: {root}\n"
        "\n"
        "bundles: {}\n",
        encoding="utf-8",
    )
    print(f"wrote {path}")
    print(f"  version         {versions.render(versions.skill_version())}")
    print(f"  workspace_root  {root}")
    print(f"\nAdd a bundle with `{me} add`.")
    return 0


def cmd_add(args: argparse.Namespace) -> int:
    """Register one bundle, by whichever of the three routes it arrived on.

    The bundle's identity is the one argument this refuses to default. Asked to
    add "a public read-only vault" with no repository named, the right move is to
    stop; inferring it from the only candidate on disk was right once and would
    have been wrong the moment a second one existed.
    """
    path = workspace.manifest_path()
    if not path.is_file():
        sys.exit(f"fkb: no workspace manifest at {path}. Run `{workspace.invocation()} init` first.")
    data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    bundles = data.get("bundles") or {}
    if args.name in bundles:
        sys.exit(f"fkb: `{args.name}` is already in the manifest; edit {path} to change it")
    raw_root = data.get("workspace_root")
    root = Path(raw_root).expanduser() if raw_root else None

    scaffolded = False
    repository = False
    if args.clone:
        if root is None:
            sys.exit("fkb: cloning needs a `workspace_root` in the manifest to clone into")
        checkout = arrival.clone(args.clone, root)
        bundle_root = Path(args.path) if args.path else arrival.find_bundle_root(checkout)
    elif args.new:
        if root is None:
            sys.exit("fkb: creating a bundle needs a `workspace_root` in the manifest")
        bundle_root = arrival.scaffold(root / args.name, args.name, date.today().isoformat())
        scaffolded = True
        repository = arrival.git_init(bundle_root)
    else:
        bundle_root = Path(args.path).expanduser().resolve()
        if not bundle_root.is_dir():
            sys.exit(f"fkb: {bundle_root} is not a directory")
        if not (bundle_root / "index.md").is_file():
            bundle_root = arrival.find_bundle_root(bundle_root)

    entry: dict = {
        "path": str(bundle_root),
        "referenceable_by": _citers(args.referenceable_by),
        "writable": bool(args.writable),
    }
    if args.sync:
        if not args.writable:
            sys.exit(
                "fkb: `--sync` says commits made here may leave the machine, which only means "
                "something for a bundle this machine may write to. Add `--writable`, or drop it."
            )
        entry["sync"] = args.sync
        checked_out = arrival.checkout(bundle_root, args.sync)

    if args.publish_sample:
        if not args.publish_sample_path:
            sys.exit("fkb: `--publish-sample` needs `--publish-sample-path`, the concept it is the page for")
        derived = arrival.derive_publish(args.publish_sample, args.publish_sample_path)
        entry["publish"] = {"url": derived.url, "style": derived.style}
    elif args.publish_url:
        if not args.publish_style:
            sys.exit(f"fkb: `--publish-url` needs `--publish-style` ({', '.join(workspace.Publish.STYLES)})")
        checked = workspace.Publish(args.name, {"url": args.publish_url, "style": args.publish_style})
        entry["publish"] = {"url": checked.url, "style": checked.style}

    bundles[args.name] = entry
    data["bundles"] = bundles
    path.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False), encoding="utf-8")

    # Printed back because what entered the federation should be visible before
    # it is used, and because `add` had to ask for policy it could not infer.
    print(f"registered `{args.name}` in {path}:")
    print(textwrap.indent(yaml.safe_dump({args.name: entry}, sort_keys=False).rstrip(), "  "))

    if args.sync:
        if checked_out:
            print(f"\nThe checkout is on `{args.sync}`, which is where filing into a shared bundle belongs.")
        else:
            print(f"\nCould not check `{args.sync}` out here, so this checkout is on whatever it was.")
            print("Until it is on that branch, `sync` will refuse to push and `lint` will say so.")

    if scaffolded and repository:
        print(f"\n`git init` ran in {bundle_root}; nothing is committed yet.")
        print("Still to do, and neither is this command's to decide: pin the bundle's")
        print("pre-commit hooks at a revision you look up now, and make the first commit.")
    elif scaffolded:
        print(f"\nNo usable git, so {bundle_root} is a plain directory.")
        print("Every check here runs on a directory; history can be added later.")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(prog="fkb", description=__doc__.splitlines()[0])
    sub = parser.add_subparsers(dest="command", required=True)

    listing = sub.add_parser("list", help="bundles, paths, tiers and publish URLs")
    listing.set_defaults(func=cmd_list)

    version = sub.add_parser("version", help="this skill's release, and the one this workspace stands at")
    version.set_defaults(func=cmd_version)

    migrate = sub.add_parser("migrate", help="the migration guides this workspace still owes")
    migrate.add_argument(
        "--done",
        action="store_true",
        help="record that every guide has been worked through; declining one still counts",
    )
    migrate.set_defaults(func=cmd_migrate)

    linting = sub.add_parser("lint", help="check a bundle against OKF and its declared floor")
    linting.add_argument("bundle", nargs="?", help="one bundle by name; omit for all of them")
    linting.add_argument("--coverage", action="store_true", help="also require an index link per concept")
    linting.set_defaults(func=cmd_lint)

    url = sub.add_parser("url", help="how to cite one concept in another bundle")
    url.add_argument("bundle", help="the bundle being cited")
    url.add_argument("path", help="the concept's path beneath that bundle's root")
    url.add_argument(
        "--from",
        dest="citing",
        required=True,
        metavar="BUNDLE",
        help="the bundle doing the citing; required, because it is what the reference rule asks about",
    )
    url.set_defaults(func=cmd_url)

    resolve = sub.add_parser("resolve", help="one bundle as JSON: policy plus observed vocabulary")
    resolve.add_argument("bundle")
    resolve.set_defaults(func=cmd_resolve)

    syncing = sub.add_parser("sync", help="move commits to and from a live-shared bundle, or refuse")
    syncing.add_argument("bundle", nargs="?", help="one bundle by name; omit for every shared one")
    syncing.add_argument(
        "--check",
        action="store_true",
        help="report what would happen and change nothing; a flag rather than a second command, "
        "so the dry run and the real one cannot come to disagree about the rules",
    )
    syncing.set_defaults(func=cmd_sync)

    init = sub.add_parser("init", help="create the workspace manifest, once per machine")
    # Mutually exclusive so asking and doing cannot be requested at once: the two
    # answer different questions and only one of them writes anything.
    init_route = init.add_mutually_exclusive_group()
    init_route.add_argument(
        "--workspace-root",
        help="where bundles are checked out; relative paths in the manifest resolve under it",
    )
    init_route.add_argument(
        "--propose-roots",
        action="store_true",
        help="print the roots that make sense on this machine and exit, writing nothing",
    )
    init.set_defaults(func=cmd_init)

    add = sub.add_parser("add", help="bring a bundle into the workspace, once per bundle")
    add.add_argument("name", help="what this bundle is called in the manifest")
    arrival_route = add.add_mutually_exclusive_group(required=True)
    arrival_route.add_argument("--clone", metavar="REMOTE", help="clone a remote repo under workspace_root")
    arrival_route.add_argument("--path", help="register a checkout that is already on disk")
    arrival_route.add_argument(
        "--new", action="store_true", help="scaffold a bundle that does not exist yet"
    )
    add.add_argument(
        "--referenceable-by",
        metavar="NAMES",
        help="comma-separated bundle names, or `*`; omit to seal the bundle so nothing may cite it",
    )
    add.add_argument("--writable", action="store_true", help="allow agents to author into this bundle")
    add.add_argument(
        "--sync",
        metavar="BRANCH",
        help="the branch this bundle is shared on; the checkout is put on it and `fkb sync` may push it",
    )
    add.add_argument("--publish-url", help="the prefix its concepts hang under")
    add.add_argument("--publish-style", choices=workspace.Publish.STYLES)
    add.add_argument(
        "--publish-sample",
        metavar="URL",
        help="the address of one concept you can already open; both halves of `publish` are derived from it",
    )
    add.add_argument(
        "--publish-sample-path",
        metavar="PATH",
        help="that concept's path beneath the bundle root",
    )
    add.set_defaults(func=cmd_add)

    args = parser.parse_args()
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
