jan-cli 0.2.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
#!/usr/bin/env python3
"""Generate a categorized `jan` subtree that inlines script contents.

Output under `jan-cli/generated/scripts/`:
  scripts.yaml   — index with per-category includes
  <category>.yaml  — script groups with `help` + `run` subcommands

Each `run` materializes the script directory (and declared dependencies) to a
temp dir, prepends dependency binaries to PATH, then executes the primary file.
"""

from __future__ import annotations

import hashlib
import json
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path

import yaml

GENERATOR_VERSION = "2"
EXTS_PRI = ["sh", "zsh", "py", "js", "pl", "lua", "exs", "rb"]
SKIP_RUNTIME = frozenset({"script.meta.yaml", "README.md"})


def primary_exe(d: Path) -> Path:
    name = d.name
    for ext in EXTS_PRI:
        cand = d / f"{name}.{ext}"
        if cand.is_file():
            return cand
    globs: list[Path] = []
    for pattern in ("*.sh", "*.zsh", "*.py", "*.js"):
        globs.extend(d.glob(pattern))
    if not globs:
        raise FileNotFoundError(f"no runnable in {d}")
    return sorted(globs, key=lambda p: str(p))[0]


def read_script_meta(script_dir: Path) -> dict:
    meta_path = script_dir / "script.meta.yaml"
    if not meta_path.is_file():
        return {}
    data = yaml.safe_load(meta_path.read_text(encoding="utf-8", errors="replace"))
    return data if isinstance(data, dict) else {}


def first_non_header_line(text: str) -> str:
    for line in text.splitlines():
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        return s.replace("\t", " ")[:240]
    return ""


def readme_description(readme: Path) -> str:
    text = readme.read_text(encoding="utf-8", errors="replace")
    match = re.search(r"^##\s+Description\s*\n+(.+?)(?:\n##|\Z)", text, re.MULTILINE | re.DOTALL)
    if match:
        for line in match.group(1).strip().splitlines():
            line = line.strip()
            if line and not line.startswith("#"):
                return line.replace("\t", " ")[:240]
    return first_non_header_line(text)


def script_header_description(exe: Path) -> str:
    for line in exe.read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
        s = line.strip()
        if not s.startswith("#") or s.startswith("#!"):
            continue
        s = s.lstrip("#").strip()
        if not s or s.lower().startswith("(c)"):
            continue
        if re.match(r"^(version|author|usage):", s, re.I):
            continue
        if len(s) >= 12:
            return s[:240]
    return ""


def read_about(script_dir: Path, script_name: str) -> str:
    meta = read_script_meta(script_dir)
    for key in ("description", "about", "summary"):
        val = meta.get(key)
        if isinstance(val, str) and val.strip():
            return val.strip()[:500]

    readme = script_dir / "README.md"
    if readme.is_file():
        about = readme_description(readme).strip()
        if about:
            return about

    try:
        about = script_header_description(primary_exe(script_dir)).strip()
        if about:
            return about
    except OSError:
        pass

    return f"{script_name} utility script"


def pick_category(name: str, script_dir: Path) -> str:
    meta = read_script_meta(script_dir)
    cat = meta.get("category")
    if isinstance(cat, str) and cat.strip():
        return cat.strip().lower()

    n = name.lower()
    patterns: list[tuple[str, str]] = [
        ("android", r"^(android|adb|emulator|gradle).*|.*android.*"),
        ("git", r"^(git|gd|jj|bcommit|autogit|gitsync|gitflux|gitfile|gitfilehistory|resquash|repick|mergetest|merge-project|create-ticket|add-submodule).*|.*git.*"),
        ("docker", r"^docker.*|.*docker.*"),
        ("network", r"^(net|ip|scan|ports|wget|curl|duck|duckduckgo|web|url|myip).*|.*(lan|port|http|https|dns|ssh).*"),
        ("media", r"^(img|image|png|pdf|comic|chafa|qr|qrencoder|gif|video|vid).*|.*(png|pdf|gif|qr|video|comic).*"),
        ("tmux", r"^(tmux|agent-session).*|.*tmux.*"),
        ("time", r"^(time|pomodoro|pause|days_until|halloween|xmas|countdown|timeclock).*|.*countdown.*"),
        ("files", r"^(file|files|flatten|fullpath|basename|nospaces|notabs|rm_|remove_|folder_|compare|diffall|chunk_|filesplit|cat-).*|.*(dir|dirs|files).*"),
        ("text", r"^(to(camel|snake)case|simplify-prose|dictionary|quote|dequote|append_keyword|emoji_).*|.*(case|latex|prose|text).*"),
        ("system", r"^(sys|cputemp|lsdaemons|killport|install-deb|machinetype|upkeep|server|sdk|task|trackusage).*|.*(systemd|journal|cpu|daemon|process).*"),
    ]
    for cat_name, pat in patterns:
        if re.match(pat, n):
            return cat_name
    return "misc"


def heredoc_delim(tag: str, content: str) -> str:
    base = "JAN_EOF_" + re.sub(r"[^A-Za-z0-9_]", "_", tag)[:48]
    suffix = hashlib.sha256(content.encode("utf-8", errors="ignore")).hexdigest()[:12]
    delim = f"{base}_{suffix}"
    while delim in content:
        suffix = hashlib.sha256((suffix + "x").encode("utf-8")).hexdigest()[:12]
        delim = f"{base}_{suffix}"
    return delim


def read_file_text(path: Path) -> str:
    raw = path.read_text(encoding="utf-8", errors="replace")
    if not raw.endswith("\n"):
        raw += "\n"
    return raw


def collect_runtime_files(script_dir: Path) -> list[tuple[str, str]]:
    out: list[tuple[str, str]] = []
    for p in sorted(script_dir.iterdir()):
        if not p.is_file() or p.name in SKIP_RUNTIME:
            continue
        out.append((p.name, read_file_text(p)))
    return out


def resolve_dependencies(meta: dict, scripts_root: Path) -> list[str]:
    deps: list[str] = []
    raw = meta.get("dependencies")
    if isinstance(raw, list):
        for d in raw:
            name = str(d).strip()
            if name and (scripts_root / name).is_dir():
                deps.append(name)
    return deps


def inline_runner_bash(
    script_name: str,
    script_dir: Path,
    scripts_root: Path,
    dependencies: list[str],
) -> str:
    primary = primary_exe(script_dir)
    primary_name = primary.name
    local_files = collect_runtime_files(script_dir)

    lines = [
        "set -euo pipefail",
        'tmpdir="$(mktemp -d)"',
        'trap \'rm -rf "$tmpdir"\' EXIT',
    ]

    for fname, raw in local_files:
        delim = heredoc_delim(f"{script_name}_{fname}", raw)
        lines.append(f'cat >"$tmpdir/{fname}" <<\'{delim}\'')
        lines.append(raw.rstrip("\n"))
        lines.append(delim)
        lines.append(f'chmod +x "$tmpdir/{fname}" 2>/dev/null || true')

    if dependencies:
        lines.append('mkdir -p "$tmpdir/bin"')
        for dep in dependencies:
            dep_dir = scripts_root / dep
            dep_exe = primary_exe(dep_dir)
            dep_raw = read_file_text(dep_exe)
            delim = heredoc_delim(f"dep_{script_name}_{dep}", dep_raw)
            lines.append(f'cat >"$tmpdir/bin/{dep}" <<\'{delim}\'')
            lines.append(dep_raw.rstrip("\n"))
            lines.append(delim)
            lines.append(f'chmod +x "$tmpdir/bin/{dep}"')
        lines.append('export PATH="$tmpdir/bin:$PATH"')

    lines.append('cd "$tmpdir"')
    lines.append(f'exec "$tmpdir/{primary_name}" "$@"')
    return "\n".join(lines) + "\n"


def build_help_text(
    script_name: str,
    about: str,
    meta: dict,
    dependencies: list[str],
) -> str:
    parts = [script_name, "", about, ""]
    requires = meta.get("requires")
    if isinstance(requires, list) and requires:
        parts.append("Requires on host: " + ", ".join(str(r) for r in requires if str(r).strip()))
        parts.append("")
    if dependencies:
        parts.append("Bundled dependencies (on PATH when run): " + ", ".join(dependencies))
        parts.append("")
    env = meta.get("env")
    if isinstance(env, dict) and env:
        parts.append("Environment:")
        for k, v in env.items():
            parts.append(f"  {k}={v}")
        parts.append("")
    return "\n".join(parts).rstrip() + "\n"


def script_node(
    script_name: str,
    about: str,
    script_dir: Path,
    scripts_root: Path,
) -> dict:
    meta = read_script_meta(script_dir)
    dependencies = resolve_dependencies(meta, scripts_root)
    help_text = build_help_text(script_name, about, meta, dependencies)
    run_script = inline_runner_bash(script_name, script_dir, scripts_root, dependencies)

    node: dict = {
        "about": about,
        "path": f"../scripts/source/{script_name}",
        "commands": {
            "help": {
                "about": "Describe this script.",
                "exec": {"argv": ["bash", "-lc", f"cat <<'EOF'\n{help_text}\nEOF\n"]},
            },
            "run": {
                "about": "Run the script (inlined); forwards args.",
                "exec": {"argv": ["bash", "-lc", run_script], "passthrough": True},
            },
        },
    }
    if dependencies:
        node["dependencies"] = dependencies
    requires = meta.get("requires")
    if isinstance(requires, list) and requires:
        node["requires"] = [str(r) for r in requires if str(r).strip()]
    env = meta.get("env")
    if isinstance(env, dict) and env:
        node["env"] = {str(k): str(v) for k, v in env.items() if str(k).strip()}
    return node


def git_sha(repo: Path) -> str | None:
    try:
        out = subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "HEAD"],
            check=True,
            capture_output=True,
            text=True,
        )
        return out.stdout.strip() or None
    except (OSError, subprocess.CalledProcessError):
        return None


def write_manifest(out_dir: Path, repo: Path, category_files: list[str], script_count: int) -> None:
    manifest = {
        "generator_version": GENERATOR_VERSION,
        "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "script_count": script_count,
        "categories": category_files,
        "git_sha": git_sha(repo),
        "files": {},
    }
    for path in sorted(out_dir.glob("*.yaml")):
        data = path.read_bytes()
        manifest["files"][path.name] = hashlib.sha256(data).hexdigest()
    (out_dir / "manifest.json").write_text(
        json.dumps(manifest, indent=2) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    repo = Path(__file__).resolve().parents[2]
    scripts_root = repo / "scripts" / "source"
    out_dir = repo / "jan-cli" / "generated" / "scripts"
    out_dir.mkdir(parents=True, exist_ok=True)
    outfile = out_dir / "scripts.yaml"

    script_dirs = sorted(
        [p for p in scripts_root.iterdir() if p.is_dir() and (p / "script.meta.yaml").is_file()],
        key=lambda p: (p.name.lower(), p.name),
    )

    cats: dict[str, dict] = {}
    for d in script_dirs:
        name = d.name
        about = read_about(d, name)
        cat = pick_category(name, d)
        cats.setdefault(cat, {})
        cats[cat][name] = script_node(name, about, d, scripts_root)

    category_order = [
        "git",
        "android",
        "docker",
        "network",
        "media",
        "files",
        "text",
        "tmux",
        "time",
        "system",
        "misc",
    ]
    ordered_cats = [(c, cats[c]) for c in category_order if c in cats]

    header = (
        "# AUTO-GENERATED — run: python3 jan-cli/scripts/generate_scripts_jan_spec.py\n"
        "# Categorized scripts with directory-inlined `run` and standard `help`.\n"
    )

    category_names: list[str] = []
    for cat, scripts in ordered_cats:
        cat_path = out_dir / f"{cat}.yaml"
        category_names.append(f"{cat}.yaml")
        cat_node = {
            "about": f"{cat} utilities",
            "commands": {k: scripts[k] for k in sorted(scripts.keys(), key=lambda s: s.lower())},
        }
        cat_path.write_text(
            header + yaml.safe_dump(cat_node, sort_keys=False, allow_unicode=True),
            encoding="utf-8",
        )

    scripts_subtree: dict = {
        "about": "Personal utilities from the monorepo `scripts/source` tree (auto-generated).",
        "commands": {cat: {"include": f"{cat}.yaml"} for cat, _ in ordered_cats},
    }
    outfile.write_text(
        header + yaml.safe_dump(scripts_subtree, sort_keys=False, allow_unicode=True),
        encoding="utf-8",
    )

    write_manifest(out_dir, repo, category_names, len(script_dirs))
    print(f"wrote {outfile}, {len(ordered_cats)} categories, {len(script_dirs)} scripts")


if __name__ == "__main__":
    main()