← Files AMDARCHIVED FILE

skills/local-ai-use/scripts/setup_local_ai.py

23.5 KB · Oct 4, 2026 · 12:28 UTC

↓ Download file

#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
# Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
#
# See LICENSE for license information.

"""One-shot setup for the `local-ai-use` skill.

Performs the setup steps from SKILL.md:

  1. Ensures modern Lemonade is installed and its background service (the
     `lemond` daemon) is reachable on http://localhost:13305 (override with
     --host / --port or LEMONADE_HOST / LEMONADE_PORT). If no modern
     `lemonade` CLI is found, the latest version is installed on the user's
     behalf. The daemon auto-starts on install and is managed by the OS
     service manager, so this script never runs a `serve` command; it waits
     for the service to come up and, if it does not, prints the OS-specific
     start command and exits non-zero.
  2. Writes the routing rule from `templates/local-ai-rule.md` into
     <workspace>/AGENTS.md, between stable BEGIN/END markers so re-runs
     replace the block in place rather than appending.

Modern Lemonade unified everything under a single `lemonade` CLI (subcommands
`status`, `pull`, `run`, ...) driving an always-on `lemond` service. That is
the only CLI this skill supports. An older build left on PATH lacks those
subcommands, so the check below probes *capability* (does `lemonade status`
work?) rather than trusting the binary name, and reports a stale CLI clearly
instead of driving it into a 90-second dead end.

Setup never downloads models: the default image/TTS/STT models are pulled
on first use, by the installed AGENTS.md rule (see its failure
handling). This keeps setup fast and offline-friendly.

The script is idempotent: a second run on a fully configured workspace only
re-runs the healthcheck. It exits non-zero on any unrecoverable failure.
Pass --no-install to refuse the automatic install (it then just reports the
missing CLI and exits non-zero, the old behaviour).

Constants are documented inline; nothing is magical.
"""

from __future__ import annotations

import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

# Defaults match the system-wide Lemonade Server install. Both the CLI
# (LEMONADE_HOST / LEMONADE_PORT) and the OpenAI-compatible HTTP endpoints
# bind to these by default.
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 13305

# Picked because each default fits in under ~5 GB and runs on commodity CPU
# hardware, so the savings vs. cloud calls are real on a typical developer
# laptop. See SKILL.md for upgrade paths.
DEFAULT_IMAGE_MODEL = "SD-Turbo"
DEFAULT_TTS_MODEL = "kokoro-v1"
DEFAULT_STT_MODEL = "Whisper-Tiny"

# Stable markers around the rule block in AGENTS.md. The script rewrites the
# region between these markers in place; do not change the marker strings or
# every existing AGENTS.md will get a duplicate block on the next run.
BEGIN_MARKER = "<!-- BEGIN amd-skills:local-ai-use -->"
END_MARKER = "<!-- END amd-skills:local-ai-use -->"

SKILL_DIR = Path(__file__).resolve().parent.parent
RULE_TEMPLATE = SKILL_DIR / "templates" / "local-ai-rule.md"

# Windows: winget is the current recommended install and keeps Lemonade
# upgradable in place, so we try it first.
WINDOWS_WINGET_ID = "AMD.LemonadeServer"

# Fallback when winget is unavailable: the *full* Windows installer, Lemonade
# plus the desktop app and the always-on `lemond` service.
# `releases/latest/download/<asset>` always resolves to the newest published
# asset of that exact name, so we never have to pin a version.
WINDOWS_MSI_URL = (
    "https://github.com/lemonade-sdk/lemonade/releases/latest/download/lemonade.msi"
)
# Default per-user install location used by lemonade.msi. The CLI is added to
# the *user* PATH in the registry, which the current process will not see, so
# we also probe this tree directly after installing.
WINDOWS_INSTALL_DIR = Path(
    os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))
) / "lemonade_server"

# macOS: the Homebrew cask is the current recommended install. It also avoids
# the unauthenticated GitHub API call the .pkg fallback needs, which is rate
# limited per source IP.
MACOS_BREW_CASK = "lemonade-server"

# GitHub release metadata, used to resolve the versioned macOS .pkg asset
# (its filename embeds the version, so there is no stable latest/download URL).
GITHUB_LATEST_RELEASE_API = (
    "https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest"
)

# Ubuntu/Debian install: the stable PPA. The apt package is named
# `lemonade-server` (the CLI you then run is `lemonade`); installing it pulls
# in the `lemond` service, which the OS auto-starts. Run as a single shell
# pipeline so one sudo prompt covers the whole thing.
LINUX_APT_INSTALL = (
    "sudo add-apt-repository -y ppa:lemonade-team/stable && "
    "sudo apt-get update && "
    "sudo apt-get install -y lemonade-server"
)

# Modern Lemonade exposes exactly one CLI, `lemonade`, driving the always-on
# `lemond` service. There is no second binary to fall back to, so instead of
# trying other names we verify this one is the modern build (see find_cli).
CLI_NAME = "lemonade"

# Docs URL to point users at when they must install/upgrade Lemonade by hand.
INSTALL_DOCS_URL = "https://lemonade-server.ai/docs/guide/install/"


def _default_workspace() -> Path:
    """Workspace root for AGENTS.md.

    Defaults to cwd, but if launched from inside an agent's skill folder
    (the universal `<.dot-config>/skills/<skill>/` layout used by Claude,
    Cursor, Codex, Gemini, etc.), climb out to the real workspace root so
    AGENTS.md is never buried inside the skill folder.
    """
    cwd = Path.cwd().resolve()
    for parent in cwd.parents:
        if parent.name == "skills" and parent.parent.name.startswith("."):
            return parent.parent.parent
    return cwd


def _print(msg: str) -> None:
    """Single-line, prefix-tagged status print so the agent's output stays parseable."""
    print(f"[local-ai-use] {msg}", flush=True)


def _http_get(url: str, timeout_s: float) -> tuple[int, bytes]:
    req = urllib.request.Request(url)
    with urllib.request.urlopen(req, timeout=timeout_s) as r:  # noqa: S310
        return r.status, r.read()


def _candidate_clis() -> list[str]:
    """Every `lemonade` executable we can find, PATH first.

    On Windows the MSI updates the *user* PATH in the registry, which the
    current process will not have inherited, so we also probe the default
    per-user install tree for the executable.
    """
    candidates: list[str] = []
    found = shutil.which(CLI_NAME)
    if found:
        candidates.append(found)
    if platform.system() == "Windows" and WINDOWS_INSTALL_DIR.exists():
        for exe in WINDOWS_INSTALL_DIR.rglob(f"{CLI_NAME}.exe"):
            exe_str = str(exe)
            if exe_str not in candidates:
                candidates.append(exe_str)
    return candidates


def is_modern_cli(cli: str) -> bool:
    """True if `cli` is the modern Lemonade CLI (drives the `lemond` service).

    Capability probe, not a name check: the modern `lemonade` CLI exposes a
    `status` subcommand that reports on the service. An older `lemonade` on
    PATH does not recognise the subcommand and errors with an argparse
    "invalid choice" instead. Running `lemonade status` is cheap, does not
    mutate anything, and tells us both that the CLI is modern AND whether the
    service is already up, so we reuse it as the single discriminator.

    A modern CLI prints "Server is running..." or "Server is not running"
    (exiting 0 or 1 accordingly). We key off that phrasing rather than the
    exit code alone.
    """
    try:
        result = subprocess.run(
            [cli, "status"],
            capture_output=True,
            text=True,
            timeout=20,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    blob = f"{result.stdout}\n{result.stderr}".lower()
    # Modern `lemonade status` always reports on the server, whether or not it
    # is running. An old/incompatible CLI never prints this phrasing (it errors
    # with "invalid choice: 'status'"), so the presence of the phrase is a
    # positive, unambiguous signal that this is the modern CLI.
    return "server is running" in blob or "server is not running" in blob


def find_cli() -> tuple[str | None, str | None]:
    """Locate a Lemonade CLI and classify it.

    Returns ``(modern_cli, stale_cli)``:

    - ``(path, None)``  a modern, capable `lemonade` CLI was found -> use it.
    - ``(None, path)``  a `lemonade` executable exists but is an older build
                        -> the caller should guide the user to upgrade.
    - ``(None, None)``  no `lemonade` executable found at all -> install it.
    """
    stale: str | None = None
    for cli in _candidate_clis():
        if is_modern_cli(cli):
            return cli, None
        stale = stale or cli
    return None, stale


def install_lemonade() -> None:
    """Install the latest version of Lemonade for the current OS.

    Raises RuntimeError on any unrecoverable failure so the caller can report
    a clean message and fall back to the manual install link.
    """
    system = platform.system()
    if system == "Windows":
        _install_windows()
    elif system == "Linux":
        _install_linux()
    elif system == "Darwin":
        _install_macos()
    else:
        raise RuntimeError(
            f"No automatic installer for this OS ({system}). "
            f"Install manually: {INSTALL_DOCS_URL}"
        )


def _download(url: str, dest: Path) -> None:
    _print(f"downloading {url}")
    try:
        urllib.request.urlretrieve(url, dest)  # noqa: S310
    except (urllib.error.URLError, OSError) as exc:
        raise RuntimeError(f"download failed ({url}): {exc}") from exc


def _run(cmd: list[str] | str, *, shell: bool = False) -> None:
    """Run an install command, surfacing a clean error on failure."""
    printable = cmd if isinstance(cmd, str) else " ".join(cmd)
    _print(f"running: {printable}")
    result = subprocess.run(cmd, shell=shell)  # noqa: S602,S603
    if result.returncode != 0:
        raise RuntimeError(f"command failed (exit {result.returncode}): {printable}")


def _install_windows() -> None:
    """Install with winget, falling back to the signed full MSI."""
    if shutil.which("winget") is not None:
        try:
            _run(
                [
                    "winget",
                    "install",
                    "-e",
                    "--id",
                    WINDOWS_WINGET_ID,
                    "--accept-package-agreements",
                    "--accept-source-agreements",
                    "--disable-interactivity",
                ]
            )
            _print("Lemonade installed.")
            return
        except RuntimeError as exc:
            _print(f"winget install did not complete ({exc}); trying the MSI.")
    msi = Path(tempfile.gettempdir()) / "lemonade.msi"
    _download(WINDOWS_MSI_URL, msi)
    # /qn = silent, per-user (no elevation needed). The MSI registers the CLI
    # and Start Menu shortcut and pulls the full app payload.
    _run(["msiexec", "/i", str(msi), "/qn"])
    _print("Lemonade installed.")


def _install_linux() -> None:
    """Install from the stable PPA on apt distros."""
    if shutil.which("apt-get") is None:
        raise RuntimeError(
            "Automatic install only supports apt-based distros (Ubuntu/Debian). "
            f"Install manually: {INSTALL_DOCS_URL}"
        )
    if os.geteuid() != 0 and shutil.which("sudo") is None:  # type: ignore[attr-defined]
        raise RuntimeError(
            "Need root (or sudo) to install system packages. "
            f"Install manually: {INSTALL_DOCS_URL}"
        )
    _run(LINUX_APT_INSTALL, shell=True)
    _print("Lemonade installed.")


def _install_macos() -> None:
    """Install the Homebrew cask, falling back to the signed .pkg."""
    if shutil.which("brew") is not None:
        try:
            _run(["brew", "install", "--cask", MACOS_BREW_CASK])
            _print("Lemonade installed.")
            return
        except RuntimeError as exc:
            _print(f"Homebrew install did not complete ({exc}); trying the .pkg.")
    pkg_url = _resolve_macos_pkg_url()
    pkg = Path(tempfile.gettempdir()) / "Lemonade.pkg"
    _download(pkg_url, pkg)
    _run(["sudo", "installer", "-pkg", str(pkg), "-target", "/"])
    _print("Lemonade installed.")


def _resolve_macos_pkg_url() -> str:
    """Resolve the versioned macOS .pkg download URL from the latest release."""
    req = urllib.request.Request(
        GITHUB_LATEST_RELEASE_API, headers={"Accept": "application/vnd.github+json"}
    )
    try:
        with urllib.request.urlopen(req, timeout=15.0) as r:  # noqa: S310
            data = json.loads(r.read())
    except (urllib.error.URLError, OSError, ValueError) as exc:
        raise RuntimeError(f"could not query latest release: {exc}") from exc
    for asset in data.get("assets", []):
        name = asset.get("name", "")
        if name.endswith("-Darwin.pkg"):
            return asset["browser_download_url"]
    raise RuntimeError(
        "No macOS .pkg asset found in the latest release. "
        f"Install manually: {INSTALL_DOCS_URL}"
    )


def service_start_hint() -> str:
    """OS-specific command to (re)start the `lemond` service by hand.

    Modern Lemonade has no `lemonade serve`; the `lemond` daemon is managed by
    the OS service manager and auto-starts on install. If it is somehow down,
    the user starts it via their service manager, not via the CLI.
    """
    system = platform.system()
    if system == "Linux":
        # v11 ships a system service; older 10.x used a --user unit. Offer both.
        return (
            "sudo systemctl start lemond   "
            "(or, for a per-user install: systemctl --user start lemond)"
        )
    if system == "Darwin":  # macOS
        return "sudo launchctl load /Library/LaunchDaemons/com.lemonade.server.plist"
    if system == "Windows":
        return (
            "start the Lemonade tray app from the Start menu, or run "
            "`Start-Service lemond` (or `net start lemond`) in an elevated shell"
        )
    return f"start the Lemonade service for your OS; see {INSTALL_DOCS_URL}"


def uninstall_hint() -> str:
    """OS-specific ways to remove an old Lemonade that shadows the modern CLI."""
    system = platform.system()
    if system == "Linux":
        return "`sudo apt remove lemonade-server`, or the package manager it came from"
    if system == "Darwin":  # macOS
        return (
            f"`brew uninstall --cask {MACOS_BREW_CASK}`, or delete the installed "
            "`Lemonade.app` and its .pkg receipt"
        )
    if system == "Windows":
        return (
            f"`winget uninstall -e --id {WINDOWS_WINGET_ID}`, or uninstall "
            "Lemonade Server from Settings > Apps > Installed apps"
        )
    return f"remove it with your platform's package manager; see {INSTALL_DOCS_URL}"


def wait_for_server(host: str, port: int, timeout_s: float = 90.0) -> bool:
    """Poll /api/v1/health until it answers 200 or we hit the timeout."""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        if check_server_reachable(host, port):
            return True
        time.sleep(2.0)
    return False


def check_server_reachable(host: str, port: int) -> bool:
    """Return True if /api/v1/health responds 200 within 3 seconds."""
    url = f"http://{host}:{port}/api/v1/health"
    try:
        status, _ = _http_get(url, timeout_s=3.0)
        return status == 200
    except (urllib.error.URLError, OSError):
        return False


def render_rule_block(
    *,
    host: str,
    port: int,
    image_model: str,
    tts_model: str,
    stt_model: str,
) -> str:
    """Read the rule template and fill in endpoint/model choices.

    The template already includes BEGIN/END markers and matches the constants
    at the top of this file. We re-validate that here so a future template
    edit cannot silently drift away from the markers the writer relies on.
    """
    if not RULE_TEMPLATE.exists():
        raise FileNotFoundError(
            f"Rule template missing: {RULE_TEMPLATE}. "
            "Did the skill folder get partially copied?"
        )
    text = RULE_TEMPLATE.read_text(encoding="utf-8")
    if BEGIN_MARKER not in text or END_MARKER not in text:
        raise ValueError(
            "Rule template is missing the BEGIN/END markers; refuse to write "
            "AGENTS.md because re-runs would append duplicate blocks."
        )
    endpoint_host = "localhost" if host in {"127.0.0.1", "::1"} else host
    base_root = f"http://{endpoint_host}:{port}"
    replacements = {
        "{{LEMONADE_BASE_ROOT}}": base_root,
        "{{LEMONADE_BASE_URL}}": f"{base_root}/api/v1",
        "{{IMAGE_MODEL}}": image_model,
        "{{TTS_MODEL}}": tts_model,
        "{{STT_MODEL}}": stt_model,
    }
    for placeholder, value in replacements.items():
        text = text.replace(placeholder, value)
    unresolved = sorted(set(re.findall(r"\{\{[A-Z_]+\}\}", text)))
    if unresolved:
        raise ValueError(
            "Rule template still has unresolved placeholders: "
            + ", ".join(unresolved)
        )
    return text.strip() + "\n"


def upsert_agents_md(
    workspace: Path,
    *,
    host: str,
    port: int,
    image_model: str,
    tts_model: str,
    stt_model: str,
) -> Path:
    """Write or replace the rule block inside <workspace>/AGENTS.md."""
    target = workspace / "AGENTS.md"
    block = render_rule_block(
        host=host,
        port=port,
        image_model=image_model,
        tts_model=tts_model,
        stt_model=stt_model,
    )

    if not target.exists():
        workspace.mkdir(parents=True, exist_ok=True)
        target.write_text(
            "# Agent instructions\n\n"
            "Project-scoped rules picked up automatically by Cursor, Claude Code,\n"
            "Codex, Gemini CLI, and other AGENTS.md-aware coding agents.\n\n"
            f"{block}",
            encoding="utf-8",
        )
        _print(f"created {target}")
        return target

    existing = target.read_text(encoding="utf-8")
    if BEGIN_MARKER in existing and END_MARKER in existing:
        before, _, rest = existing.partition(BEGIN_MARKER)
        _, _, after = rest.partition(END_MARKER)
        # Strip trailing newline noise around the spliced region so we don't
        # accumulate blank lines on every re-run.
        new = before.rstrip() + "\n\n" + block + after.lstrip()
        if new == existing:
            _print(f"AGENTS.md rule already up to date at {target}")
            return target
        target.write_text(new, encoding="utf-8")
        _print(f"updated rule block in {target}")
        return target

    # No existing block: append with a separating blank line.
    if not existing.endswith("\n"):
        existing += "\n"
    target.write_text(existing + "\n" + block, encoding="utf-8")
    _print(f"appended rule block to {target}")
    return target


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--workspace",
        type=Path,
        default=_default_workspace(),
        help="Workspace root where AGENTS.md should be written (default: workspace root, auto-detected).",
    )
    parser.add_argument(
        "--host",
        default=os.environ.get("LEMONADE_HOST", DEFAULT_HOST),
        help="Lemonade Server host (default: 127.0.0.1 / $LEMONADE_HOST).",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=int(os.environ.get("LEMONADE_PORT", str(DEFAULT_PORT))),
        help="Lemonade Server port (default: 13305 / $LEMONADE_PORT).",
    )
    parser.add_argument(
        "--image-model",
        default=DEFAULT_IMAGE_MODEL,
        help=f"Image generation model written into AGENTS.md, pulled on first use (default: {DEFAULT_IMAGE_MODEL}).",
    )
    parser.add_argument(
        "--tts-model",
        default=DEFAULT_TTS_MODEL,
        help=f"Text-to-speech model written into AGENTS.md, pulled on first use (default: {DEFAULT_TTS_MODEL}).",
    )
    parser.add_argument(
        "--stt-model",
        default=DEFAULT_STT_MODEL,
        help=f"Speech-to-text model written into AGENTS.md, pulled on first use (default: {DEFAULT_STT_MODEL}).",
    )
    parser.add_argument(
        "--no-install",
        action="store_true",
        help="Do not auto-install Lemonade; just report and exit non-zero if the CLI or service is missing.",
    )
    args = parser.parse_args(argv)

    cli, stale = find_cli()

    # An old `lemonade` on PATH shadows the modern CLI. We never drive or
    # auto-remove it -- we guide the user, because a shadowing binary will keep
    # hiding a freshly installed modern CLI until it is removed.
    if cli is None and stale is not None:
        _print(f"FAIL: found an old Lemonade CLI at {stale}.")
        _print(
            "It is missing the modern `lemonade status` command, so it predates "
            "the unified CLI (v10.1.0) and cannot be used by this skill."
        )
        _print(f"Uninstall it first so it stops shadowing the modern CLI: {uninstall_hint()}.")
        _print(
            "Then re-run this skill to install the latest Lemonade for you, or "
            f"install it yourself: {INSTALL_DOCS_URL}"
        )
        return 2

    if cli is None:
        if args.no_install:
            _print("FAIL: the `lemonade` CLI is not on PATH (--no-install set).")
            _print(f"Install Lemonade manually: {INSTALL_DOCS_URL}")
            return 2
        _print("`lemonade` CLI not found; installing the latest version of Lemonade.")
        try:
            install_lemonade()
        except RuntimeError as exc:
            _print(f"FAIL: automatic install did not complete: {exc}")
            return 2
        cli, stale = find_cli()
        if cli is None:
            _print("FAIL: install finished but a modern `lemonade` CLI is still not found.")
            _print(
                "Open a new shell so PATH refreshes and re-run, or install "
                f"manually: {INSTALL_DOCS_URL}"
            )
            return 2
    _print(f"using Lemonade CLI: {cli}")

    # Modern Lemonade auto-starts the `lemond` service on install; there is no
    # `lemonade serve`. If it is not up yet (e.g. still starting right after a
    # fresh install), poll briefly, then guide the user to start the OS
    # service rather than trying to spawn it ourselves.
    if not check_server_reachable(args.host, args.port):
        if args.no_install:
            _print(
                f"FAIL: Lemonade Server is not responding at "
                f"http://{args.host}:{args.port}/api/v1/health (--no-install set)."
            )
            _print(f"Start the service: {service_start_hint()}")
            return 3
        _print("Lemonade service not reachable yet; waiting for it to come up.")
        if not wait_for_server(args.host, args.port):
            _print(
                f"FAIL: the Lemonade service did not become reachable at "
                f"http://{args.host}:{args.port}/api/v1/health."
            )
            _print(f"Start it manually, then re-run: {service_start_hint()}")
            _print(f"If it is not installed, see {INSTALL_DOCS_URL}")
            return 3

    _print(f"server reachable at http://{args.host}:{args.port}")

    upsert_agents_md(
        args.workspace.resolve(),
        host=args.host,
        port=args.port,
        image_model=args.image_model,
        tts_model=args.tts_model,
        stt_model=args.stt_model,
    )
    _print("done. Future image, TTS, and STT requests now route to local Lemonade.")
    return 0


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

SHA-256: 7671a929c91974ed56704b70a44bce9102e8150937e6e743b798d421efb49825