#!/usr/bin/env python3
"""Generate each runtime's role files from one source.

    formwork roles                 write files for the runtime in .formwork.toml
    formwork roles --all           write files for every runtime
    formwork roles --runtime codex just that one
    formwork roles --check         regenerate in memory and compare. Change nothing

Exit status:
    0   done, or --check found everything current
    1   --check found a generated file that is stale or hand-edited
    2   could not run

WHY GENERATE AT ALL
-------------------
Four runtimes want role definitions in four shapes. Keeping four hand-written
copies of twenty-seven roles is the duplication that drifts — one gets updated
and the others quietly do not, and nothing announces it.

One source, generated outward, with a check that regeneration produces
identical bytes. A hand-edit to a generated file then fails the gate instead of
surviving.

WHAT EACH RUNTIME GETS, AND WHY CURSOR GETS NOTHING
---------------------------------------------------
    claude-code   .claude/agents/<name>.md      markdown, tools by name
    gemini-cli    .gemini/agents/<name>.md      markdown, tools by name
    codex         .codex/agents/<name>.toml     TOML, no tool list at all
    cursor        nothing is written

Cursor's own documentation names `.claude/agents/` as a location it reads. So
the Claude Code output serves it directly, and generating a second identical
tree would be duplication for its own sake.

THE TOOL GRANT DOES NOT SURVIVE EVERY TRANSLATION
-------------------------------------------------
Claude Code and Gemini CLI take a named list and enforce it. Cursor has one
boolean. Codex has a sandbox mode, which is not a tool list.

This generator writes the grant where it can be expressed and **says in the
generated file where it cannot**, rather than emitting something that looks
like a restriction and is not. That was decided deliberately; the two rejected
alternatives are recorded in docs/role-formats.md.

FOUR OPTIONAL KEYS, AND WHERE THEY GO
-------------------------------------
A role may also carry `description`, `model`, `skills` and `color`. They are
for a role somebody wrote as a Claude Code agent before they had this kit, and
converting it must not cost them what they had.

    description   used instead of the Owns sentence, on every runtime
    model         Claude Code only, passed through as written
    skills        Claude Code only, written back as a YAML list
    color         Claude Code only, passed through as written

The generator used to read all of them and keep none, so a role that asked for
one model ran on the default, and nothing said so. On a runtime with no
documented way to express model, skills or colour, the generated file says
that the role asked for them, the same way it does for the tool grant. Writing
a key there that does nothing would be the same bug facing the other way.

A role with none of the four generates exactly what it did before.

SKILLS, CLAUDE CODE ONLY
------------------------
A skill the kit ships lives in formwork/skills/<name>/: the upstream SKILL.md
and LICENSE, untouched, and an optional local.md holding the kit's own change.
For Claude Code this writes .claude/skills/<name>/SKILL.md (upstream, then
local.md appended) and a copy of LICENSE. Composing it here keeps the local
change out of the upstream file, so an update is a straight copy.

Where Codex, Gemini CLI or Cursor read a project's skills from is NOT
ESTABLISHED, so nothing is written for them. An agent there reads the files in
formwork/skills/ directly, which formwork/style.md tells it to do.

Python 3, standard library only, no dependencies.
"""
import os
import re
import sys

CLEAN, STALE, CANNOT_RUN = 0, 1, 2

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
ROLES = os.path.join(HERE, "roles")
SKILLS = os.path.join(HERE, "skills")

# Where a runtime reads a project's skills from. Only the one that is
# documented and has been used.
SKILL_TARGETS = {"claude-code": os.path.join(".claude", "skills")}

BANNER = ("GENERATED FROM %s — DO NOT EDIT.\n"
          "Change the source and run formwork roles. A hand-edit here fails "
          "the gate.")

# How a reply is written is the same for every role, so it is not repeated in
# twenty eight files. It is one page, pointed at from each generated role.
STYLE_NOTE = ("**How to write a reply:** `formwork/style.md`. If the project "
              "has a `docs/style.md`, that one wins.")

# The kit's five abstract grants, mapped to what each runtime actually calls
# them. None means the name is not established, and the generator will not
# invent one.
TOOL_NAMES = {
    "claude-code": {
        "read":  ["Read", "Glob", "Grep"],
        "write": ["Write", "Edit"],
        "run":   ["Bash"],
        "web":   ["WebFetch", "WebSearch"],
        "spawn": ["Task"],
    },
    "gemini-cli": {
        "read":  ["read_file", "grep_search"],
        "run":   ["run_shell_command"],
        # Established from the publisher's own example. The name of the
        # file-writing tool is NOT among the documented examples, and guessing
        # it would produce an allowlist that silently omits a tool the role
        # needs. See docs/role-formats.md.
        "write": None,
        "web":   None,
        "spawn": None,
    },
}

# Runtimes that can hold a role to its grant at all.
ENFORCES = {"claude-code", "gemini-cli"}

TARGETS = {
    "claude-code": (os.path.join(".claude", "agents"), ".md"),
    "gemini-cli":  (os.path.join(".gemini", "agents"), ".md"),
    "codex":       (os.path.join(".codex", "agents"), ".toml"),
}
ALL_RUNTIMES = tuple(sorted(TARGETS)) + ("cursor",)


def read_roles():
    out = []
    for sub in ("method", "packs", "project"):
        d = os.path.join(ROLES, sub)
        if not os.path.isdir(d):
            continue
        for fn in sorted(os.listdir(d)):
            if not fn.endswith(".md") or fn.upper().startswith(("TEMPLATE",
                                                                "HOW-TO",
                                                                "README")):
                continue
            out.append(os.path.join(d, fn))
    return out


def unquote(s):
    if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
        return s[1:-1]
    return s


def parse(path):
    text = open(path, encoding="utf-8").read()
    m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S)
    if not m:
        return None
    meta = {}
    items = {}
    key = None
    for line in m.group(1).split("\n"):
        # `skills:` followed by indented `- name` lines, the way Claude Code
        # agents write it. Read line by line, those items had no colon and
        # vanished, so the list came out empty.
        item = re.match(r"^\s+-\s+(.*)$", line)
        if item and key is not None:
            items.setdefault(key, []).append(unquote(item.group(1).strip()))
            continue
        if ":" in line:
            k, v = line.split(":", 1)
            key = k.strip()
            meta[key] = v.strip()
    body = m.group(2).lstrip("\n")
    # Accept both `tools: ["read", "write"]` and `tools: [read, write]`.
    # Reading only the quoted form meant an unquoted list parsed as NO grants,
    # which emitted frontmatter with no tools key at all — and in Claude Code
    # that grants every tool. The safest-looking role got the widest grant.
    raw = meta.get("tools", "")
    tools = re.findall(r'"([a-z]+)"', raw) or re.findall(r"\b([a-z]+)\b", raw)
    owns = re.search(r"^\*\*Owns\.\*\*\s*(.+?)(?:\n\n|\Z)", body, re.S | re.M)
    description = " ".join(owns.group(1).split()) if owns else meta.get("name", "")
    if len(description) > 300:
        description = description[:297].rsplit(" ", 1)[0] + "…"
    # The four optional keys. Present means the author wrote the key; an empty
    # value is refused later rather than quietly treated as absent.
    extra = {}
    for k in ("description", "model", "color"):
        if k in meta:
            extra[k] = meta[k]
    if "skills" in meta:
        raw = meta["skills"]
        if raw.startswith("[") and raw.endswith("]"):
            raw = raw[1:-1]
        listed = [unquote(s.strip()) for s in raw.split(",")]
        extra["skills"] = [s for s in listed + items.get("skills", []) if s]
    if extra.get("description"):
        # The author's own trigger text. Claude Code picks an agent by it, so
        # it is used whole rather than cut to the length of an Owns sentence.
        description = extra["description"]
    return {"name": meta.get("name", ""), "pack": meta.get("pack", ""),
            "owns": meta.get("owns", ""), "tools": tools,
            "description": description, "body": body, "extra": extra,
            "source": os.path.relpath(path, ROOT)}


def resolve_tools(runtime, grants):
    """(names, unmapped). names is None when the list cannot be completed."""
    table = TOOL_NAMES.get(runtime)
    if table is None:
        return None, list(grants)
    names, unmapped = [], []
    for g in grants:
        got = table.get(g)
        if got is None:
            unmapped.append(g)
        else:
            names.extend(got)
    if unmapped:
        return None, unmapped
    return names, []


def asked_for(role):
    """What the role asked for beyond its tools, in words, or None.

    For a runtime that cannot be told. Whether Codex or Gemini CLI has an
    equivalent of model, skills or color is NOT ESTABLISHED, and a guessed key
    name would be a setting that looks like it works and does not.
    """
    extra = role["extra"]
    said = []
    if extra.get("model"):
        said.append("model `%s`" % extra["model"])
    if extra.get("skills"):
        said.append("skills %s" % ", ".join("`%s`" % s
                                            for s in extra["skills"]))
    if extra.get("color"):
        said.append("color `%s`" % extra["color"])
    return " and ".join(said) if said else None


def asked_for_note(role, runtime):
    said = asked_for(role)
    if said is None:
        return None
    return ("This role asks for %s, and %s has no documented way to say so, "
            "so none is written here. It is advice on this runtime."
            % (said, runtime))


def render_markdown(role, runtime):
    names, unmapped = resolve_tools(runtime, role["tools"])
    if not role["tools"]:
        # No grant could be read at all. Emitting frontmatter without a tools
        # key means "every tool" on some runtimes, so say so instead.
        unmapped = ["(the grant could not be read from the source)"]
    extra = role["extra"]
    lines = ["---", "name: %s" % role["name"],
             "description: %s" % role["description"]]
    if runtime == "gemini-cli":
        lines.append("kind: local")
    if names:
        if runtime == "claude-code":
            lines.append("tools: %s" % ", ".join(names))
        else:
            lines.append("tools:")
            lines += ["  - %s" % n for n in names]
    if runtime == "claude-code":
        if extra.get("model"):
            lines.append("model: %s" % extra["model"])
        if extra.get("skills"):
            lines.append("skills:")
            lines += ["  - %s" % s for s in extra["skills"]]
        if extra.get("color"):
            lines.append("color: %s" % extra["color"])
    lines.append("---")
    lines.append("")
    for line in (BANNER % role["source"]).split("\n"):
        lines.append("<!-- %s -->" % line)
    lines.append("")
    lines.append("> %s" % STYLE_NOTE)
    lines.append("")
    if unmapped:
        lines.append("> **This role's tool grant is not expressed here.** It "
                     "asks for %s, and %s does not have a documented name for "
                     "%s. An incomplete allowlist would quietly remove a tool "
                     "the role needs, so none is written. The grant is advice "
                     "on this runtime."
                     % (", ".join("`%s`" % t for t in role["tools"]),
                        runtime, " and ".join("`%s`" % u for u in unmapped)))
        lines.append("")
    note = None if runtime == "claude-code" else asked_for_note(role, runtime)
    if note:
        lines.append("> %s" % note)
        lines.append("")
    lines.append(role["body"].rstrip())
    lines.append("")
    return "\n".join(lines)


def toml_escape(s):
    return s.replace("\\", "\\\\").replace('"', '\\"')


def render_toml(role):
    banner = (BANNER % role["source"]).split("\n")
    out = ["# %s" % b for b in banner]
    out.append("")
    out.append('name = "%s"' % toml_escape(role["name"]))
    out.append('description = "%s"' % toml_escape(unquote(role["description"])))
    out.append("")
    out.append("# This runtime has a sandbox mode rather than a tool list, so")
    out.append("# the grant %s is not expressed here. It is advice on Codex."
               % ", ".join(role["tools"]))
    out.append("")
    note = asked_for_note(role, "codex")
    if note:
        out.append("# %s" % note.replace("`", ""))
        out.append("")
    out.append('developer_instructions = """')
    out.append(STYLE_NOTE.replace("**", "").replace("`", ""))
    out.append("")
    out.append(role["body"].rstrip().replace('"""', '\\"\\"\\"'))
    out.append('"""')
    out.append("")
    return "\n".join(out)


def existing_targets():
    """Runtimes whose generated tree is already present in this project.

    A fork that uses one runtime should not be made to generate files for
    three. But a tree that exists must stay current, or a stale agent
    definition survives unnoticed — which is the whole reason this generator
    exists.
    """
    out = []
    for runtime, (folder, _ext) in sorted(TARGETS.items()):
        if os.path.isdir(os.path.join(ROOT, folder)):
            out.append(runtime)
    return out


def orphans(files, runtimes):
    """Generated files with no source, in the trees we generate into.

    An agent definition added by hand, or one left behind after its source was
    deleted, used to pass unnoticed: --check only compared files the source
    produces. A hand-written agent with every tool granted is exactly what
    this kit exists to prevent.
    """
    expected = set(files)
    found = []
    for runtime in runtimes:
        if runtime == "cursor":
            continue
        folder, ext = TARGETS[runtime]
        d = os.path.join(ROOT, folder)
        if not os.path.isdir(d):
            continue
        for fn in sorted(os.listdir(d)):
            if not fn.endswith(ext):
                continue
            rel = os.path.join(folder, fn)
            if rel not in expected:
                found.append(rel)
    return found


SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")


def unsafe_names(roles):
    """Role names that are not safe to put in a path, with the reason.

    `name:` was joined into a file path with no checking at all. A role
    claiming `name: /tmp/anything` or `name: ../../../elsewhere` made the
    generator write outside the project, and the gate stayed green. Somebody
    cloning a repository that ships a kit would have run one documented
    command and had files written wherever they can write.
    """
    bad = []
    seen = {}
    for r in roles:
        n = r["name"]
        if not SAFE_NAME.match(n):
            bad.append("%s declares name: %r. A name must be lower case "
                       "letters, digits and hyphens, and nothing else."
                       % (r["source"], n))
        elif n in seen:
            # Two roles with one name silently collapsed into one file, and
            # both the shipped role and one of the pair disappeared.
            bad.append("%s and %s both declare name: %s. Names must be unique."
                       % (seen[n], r["source"], n))
        else:
            seen[n] = r["source"]
    return bad


def empty_keys(roles):
    """Optional keys that were written with nothing in them.

    Treating `model:` with no value as absent would run the role on the
    default model, which is the failure these keys exist to prevent.
    """
    bad = []
    for r in roles:
        for k in ("description", "model", "skills", "color"):
            if k in r["extra"] and not r["extra"][k]:
                bad.append("%s declares %s: with nothing in it. Give it a "
                           "value or remove the line." % (r["source"], k))
    return bad


def read_skills():
    """[(name, folder)] for every skill under formwork/skills/ with a SKILL.md."""
    if not os.path.isdir(SKILLS):
        return []
    out = []
    for name in sorted(os.listdir(SKILLS)):
        folder = os.path.join(SKILLS, name)
        if os.path.isfile(os.path.join(folder, "SKILL.md")):
            out.append((name, folder))
    return out


def render_skill(name, folder):
    """{filename: contents} for one skill: SKILL.md composed, LICENSE copied."""
    upstream = open(os.path.join(folder, "SKILL.md"), encoding="utf-8").read()
    source = "formwork/skills/%s/SKILL.md" % name
    local = os.path.join(folder, "local.md")
    has_local = os.path.isfile(local)
    if has_local:
        source += " and local.md"
    banner = "\n".join("<!-- %s -->" % line
                       for line in (BANNER % source).split("\n"))
    # The banner goes after the frontmatter. Before it, Claude Code would not
    # find the frontmatter, and the skill would have no name.
    m = re.match(r"^(---\n.*?\n---\n)(.*)$", upstream, re.S)
    if m:
        text = m.group(1) + "\n" + banner + "\n" + m.group(2)
    else:
        text = banner + "\n\n" + upstream
    if has_local:
        text = text.rstrip("\n") + "\n\n" + open(
            local, encoding="utf-8").read().rstrip("\n") + "\n"
    files = {"SKILL.md": text}
    licence = os.path.join(folder, "LICENSE")
    if os.path.isfile(licence):
        files["LICENSE"] = open(licence, encoding="utf-8").read()
    return files


def skill_outputs(runtime):
    """{path: contents} for the kit's skills on one runtime."""
    target = SKILL_TARGETS.get(runtime)
    if target is None:
        return {}
    files = {}
    for name, folder in read_skills():
        if not SAFE_NAME.match(name):
            raise ValueError("formwork/skills/%s: a skill folder name must be "
                             "lower case letters, digits and hyphens." % name)
        for fn, text in render_skill(name, folder).items():
            files[os.path.join(target, name, fn)] = text
    return files


def ours(rel, existing):
    """Whether a file already on disk is one this generator wrote.

    A skill's LICENSE carries no banner, since it is the upstream text. It is
    ours when the SKILL.md beside it is.
    """
    marker = BANNER.split(" ")[0]
    if marker in existing:
        return True
    if os.path.basename(rel) == "LICENSE":
        skill = os.path.join(ROOT, os.path.dirname(rel), "SKILL.md")
        return (os.path.isfile(skill)
                and marker in open(skill, encoding="utf-8").read())
    return False


def outputs(runtimes):
    """{path: contents} for every runtime asked for."""
    files = {}
    roles = [parse(p) for p in read_roles()]
    roles = [r for r in roles if r and r["name"]]
    if not roles:
        return None
    bad = unsafe_names(roles) + empty_keys(roles)
    if bad:
        raise ValueError("\n".join(bad))
    for runtime in runtimes:
        if runtime == "cursor":
            continue                       # reads .claude/agents/ directly
        folder, ext = TARGETS[runtime]
        for role in roles:
            rel = os.path.join(folder, role["name"] + ext)
            if ext == ".toml":
                files[rel] = render_toml(role)
            else:
                files[rel] = render_markdown(role, runtime)
        files.update(skill_outputs(runtime))
    return files


def configured_runtime():
    config = os.path.join(ROOT, ".formwork.toml")
    if os.path.exists(config):
        text = open(config, encoding="utf-8", errors="ignore").read()
        m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
        if m and m.group(1) in ALL_RUNTIMES:
            return m.group(1)
    return "claude-code"


def main(argv):
    check_only = "--check" in argv
    if "--all" in argv:
        runtimes = [r for r in ALL_RUNTIMES if r != "cursor"]
    elif check_only and "--runtime" not in argv:
        # Check the configured runtime, plus any tree already present. A fork
        # using one runtime is not made to generate files for three, and a
        # tree that does exist is still held to being current.
        runtimes = sorted(set([configured_runtime()] + existing_targets())
                          - {"cursor"})
        if not runtimes:
            runtimes = ["claude-code"]
    elif "--runtime" in argv:
        i = argv.index("--runtime")
        if i + 1 >= len(argv) or argv[i + 1] not in ALL_RUNTIMES:
            print("ERROR: --runtime needs one of: %s" % ", ".join(ALL_RUNTIMES),
                  file=sys.stderr)
            return CANNOT_RUN
        runtimes = [argv[i + 1]]
    else:
        if not os.path.exists(os.path.join(ROOT, ".formwork.toml")) \
                and not check_only:
            print("ERROR: this project has no .formwork.toml, so there is no "
                  "runtime to generate for.", file=sys.stderr)
            print("       Run the installer first, or say which: "
                  "--runtime claude-code", file=sys.stderr)
            return CANNOT_RUN
        runtimes = [configured_runtime()]

    if runtimes == ["cursor"]:
        print("cursor reads .claude/agents/, so nothing is generated for it")
        runtimes = ["claude-code"]

    try:
        files = outputs(runtimes)
    except ValueError as e:
        print("ERROR: %d problem(s) stop these roles being generated:"
              % len(str(e).split("\n")), file=sys.stderr)
        for line in str(e).split("\n"):
            print("  %s" % line, file=sys.stderr)
        return CANNOT_RUN
    if files is None:
        print("ERROR: no roles found under %s" % ROLES, file=sys.stderr)
        return CANNOT_RUN

    if check_only:
        stale = ["%s — generated, but nothing in formwork/roles/ produces it"
                 % o for o in orphans(files, runtimes)]
        for rel, content in sorted(files.items()):
            full = os.path.join(ROOT, rel)
            if not os.path.exists(full):
                stale.append("%s — missing" % rel)
            elif open(full, encoding="utf-8").read() != content:
                stale.append("%s — differs from what the source produces" % rel)
        if stale:
            print("%d generated file(s) are not current:" % len(stale))
            for s in stale:
                print("  %s" % s)
            print("  Run formwork roles. If you edited one by hand, that edit "
                  "is about to be lost — move it to the source first.")
            return STALE
        print("%d generated file(s), all current" % len(files))
        return CLEAN

    written = 0
    kept = []
    for rel, content in sorted(files.items()):
        full = os.path.join(ROOT, rel)
        os.makedirs(os.path.dirname(full), exist_ok=True)
        existing = None
        if os.path.exists(full):
            existing = open(full, encoding="utf-8").read()
        # Somebody else's agent file, with the same name as one of ours.
        # Overwriting it destroyed hand-written work with no backup and no
        # message, in a program whose own promise is that it never deletes.
        if existing is not None and existing != content \
                and not ours(rel, existing):
            kept.append(rel)
            continue
        if existing != content:
            open(full, "w", encoding="utf-8").write(content)
            written += 1
    if kept:
        print("NOT WRITTEN, because these already exist and this kit did not "
              "make them:")
        for rel in kept:
            print("  %s" % rel)
        print("  Your file is untouched. Rename yours, or rename the role in "
              "formwork/roles/, then run this again.")
    skill_files = sum(1 for rel in files
                      if rel.split(os.sep)[1:2] == ["skills"])
    print("%d generated file(s) considered (%d role, %d skill), %d written, "
          "for: %s" % (len(files), len(files) - skill_files, skill_files,
                       written, ", ".join(runtimes)))
    return CLEAN


if __name__ == "__main__":
    sys.exit(main(sys.argv))
