From fdfff8af7e848b5726d5f96b9cb8ab4d90ec6d13 Mon Sep 17 00:00:00 2001 From: Joel Spadin Date: Sat, 10 Jan 2026 15:36:31 -0600 Subject: [PATCH 1/4] feat: Add "zmk version" command Added a new "zmk version" command which can print the current ZMK version, list available tagged versions, and switch to a new version. Also added a new Remote class, which queries information about remote Git repositories, and some new utility methods on Repo to get remote and West manifest information. Updated some existing commands to use these. Partially implements #52 --- README.md | 20 +++++ zmk/commands/__init__.py | 3 +- zmk/commands/config.py | 2 +- zmk/commands/download.py | 20 +---- zmk/commands/module/add.py | 6 +- zmk/commands/module/list.py | 5 +- zmk/commands/module/remove.py | 6 +- zmk/commands/version.py | 76 ++++++++++++++++ zmk/remote.py | 158 ++++++++++++++++++++++++++++++++++ zmk/repo.py | 52 ++++++++++- 10 files changed, 314 insertions(+), 34 deletions(-) create mode 100644 zmk/commands/version.py create mode 100644 zmk/remote.py diff --git a/README.md b/README.md index cc56ef9..79acff0 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,26 @@ After pushing changes, GitHub will automatically build the firmware for you. Run From this page, you can click on a build (the latest is at the top) to view its status. If the build succeeded, you can download the firmware from the "Artifacts" section at the bottom of the build summary page. +## ZMK Version Management + +The `zmk version` command manages the version of ZMK you are using: + +```sh +zmk version # Print the current ZMK version +zmk version --list # List the available versions +zmk version # Switch to the version given by +``` + +You can set the revision to any Git tag, branch, or commit: + +```sh +zmk version v0.3 # Switch to tag "v0.3" +zmk version main # Switch to branch "main" +zmk version 1958217 # Switch to commit "1958217" +``` + +Note that `zmk version --list` will only list tagged versions. + ## Configuration The `zmk config` command manages settings for ZMK CLI: diff --git a/zmk/commands/__init__.py b/zmk/commands/__init__.py index 0227ab1..c4ba620 100644 --- a/zmk/commands/__init__.py +++ b/zmk/commands/__init__.py @@ -4,7 +4,7 @@ import typer -from . import cd, code, config, download, init, keyboard, module, west +from . import cd, code, config, download, init, keyboard, module, version, west def register(app: typer.Typer) -> None: @@ -15,6 +15,7 @@ def register(app: typer.Typer) -> None: app.command()(download.download) app.command(name="dl")(download.download) app.command()(init.init) + app.command(name="version")(version.version) app.command()(west.update) app.command( add_help_option=False, diff --git a/zmk/commands/config.py b/zmk/commands/config.py index 75a3c57..decd0df 100644 --- a/zmk/commands/config.py +++ b/zmk/commands/config.py @@ -47,7 +47,7 @@ def config( ), ] = False, ) -> None: - """Get and set ZMK CLI settings.""" + """Get or set ZMK CLI settings.""" cfg = get_config(ctx) diff --git a/zmk/commands/download.py b/zmk/commands/download.py index 9a03fe8..25db29f 100644 --- a/zmk/commands/download.py +++ b/zmk/commands/download.py @@ -4,12 +4,9 @@ import webbrowser -import giturlparse import typer from ..config import get_config -from ..exceptions import FatalError -from ..repo import Repo def download(ctx: typer.Context) -> None: @@ -18,19 +15,4 @@ def download(ctx: typer.Context) -> None: cfg = get_config(ctx) repo = cfg.get_repo() - actions_url = _get_actions_url(repo) - - webbrowser.open(actions_url) - - -def _get_actions_url(repo: Repo): - remote_url = repo.get_remote_url() - - p = giturlparse.parse(remote_url) - - match p.platform: - case "github": - return f"https://github.com/{p.owner}/{p.repo}/actions/workflows/build.yml" - - case _: - raise FatalError(f"Unsupported remote URL: {remote_url}") + webbrowser.open(repo.get_remote().firmware_download_url) diff --git a/zmk/commands/module/add.py b/zmk/commands/module/add.py index 4b6f27c..c181edf 100644 --- a/zmk/commands/module/add.py +++ b/zmk/commands/module/add.py @@ -9,7 +9,7 @@ import typer from rich.console import Console from rich.prompt import InvalidResponse, Prompt, PromptBase -from west.manifest import ImportFlag, Manifest +from west.manifest import Manifest from ...config import get_config from ...exceptions import FatalError @@ -37,9 +37,7 @@ def module_add( cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() if name: _error_if_existing_name(manifest, name) diff --git a/zmk/commands/module/list.py b/zmk/commands/module/list.py index 8cbf0f0..ec46e77 100644 --- a/zmk/commands/module/list.py +++ b/zmk/commands/module/list.py @@ -6,7 +6,6 @@ import typer from rich import box from rich.table import Table -from west.manifest import ImportFlag, Manifest from ...config import get_config @@ -19,9 +18,7 @@ def module_list(ctx: typer.Context) -> None: cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() table = Table(box=box.SQUARE, border_style="dim blue", header_style="bright_cyan") table.add_column("Name") diff --git a/zmk/commands/module/remove.py b/zmk/commands/module/remove.py index 16e52e9..d0d4a4d 100644 --- a/zmk/commands/module/remove.py +++ b/zmk/commands/module/remove.py @@ -11,7 +11,7 @@ import rich import typer -from west.manifest import ImportFlag, Manifest, Project +from west.manifest import Project from ...config import get_config from ...exceptions import FatalError @@ -32,9 +32,7 @@ def module_remove( cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() # Don't allow deleting ZMK, or the repo won't build anymore. projects = [p for p in manifest.projects[1:] if p.name != "zmk"] diff --git a/zmk/commands/version.py b/zmk/commands/version.py new file mode 100644 index 0000000..11f63c4 --- /dev/null +++ b/zmk/commands/version.py @@ -0,0 +1,76 @@ +""" +"zmk version" command. +""" + +from typing import Annotated + +import rich +import typer +from rich.table import Table + +from ..config import get_config +from ..exceptions import FatalError +from ..remote import Remote +from ..repo import Repo + + +def version( + ctx: typer.Context, + revision: Annotated[ + str | None, + typer.Argument( + help="Switch to this ZMK version. Prints the current ZMK version if omitted.", + ), + ] = None, + list_versions: Annotated[ + bool | None, + typer.Option("--list", "-l", help="Print the available versions and exit."), + ] = False, +) -> None: + """Get or set the ZMK version.""" + + cfg = get_config(ctx) + repo = cfg.get_repo() + + if list_versions: + _print_versions(repo) + elif revision is None: + _print_current_version(repo) + else: + _set_version(repo, revision) + + +def _print_versions(repo: Repo): + zmk = repo.get_west_zmk_project() + remote = Remote(zmk.url) + + if not remote.repo_exists(): + raise FatalError(f"Invalid repository URL: {zmk.url}") + + tags = remote.get_tags() + + if not tags: + raise FatalError(f"{zmk.url} does not have any tagged commits.") + + for tag in tags: + print(tag) + + +def _print_current_version(repo: Repo): + zmk = repo.get_west_zmk_project() + + grid = Table.grid() + grid.add_column() + grid.add_column() + grid.add_row("[bright_blue]Remote: [/bright_blue]", zmk.url) + grid.add_row("[bright_blue]Revision: [/bright_blue]", zmk.revision) + + rich.print(grid) + + +def _set_version(repo: Repo, revision: str): + repo.set_zmk_version(revision) + repo.run_west("update", "zmk") + + rich.print() + rich.print(f'ZMK is now using revision "{revision}"') diff --git a/zmk/remote.py b/zmk/remote.py new file mode 100644 index 0000000..6eff4c8 --- /dev/null +++ b/zmk/remote.py @@ -0,0 +1,158 @@ +""" +Methods for requesting information from a remote Git repository. +""" + +import math +import re +import subprocess + +import giturlparse + +DEFAULT_TIMEOUT = 10 + + +class Remote: + """Represents a remote Git repository""" + + url: str + + _impl: "_RemoteImpl | None" = None + + def __init__(self, url: str): + self.url = url + + p = giturlparse.parse(url) + + match p.platform: + case "github": + self._impl = _GitHubRemote(p) + + @property + def firmware_download_url(self) -> str: + """URL of a page where users can download firmware builds""" + if self._impl: + return self._impl.firmware_download_url + + raise NotImplementedError(f"Cannot get download URL for {self.url}") + + def repo_exists(self) -> bool: + """Get whether the remote URL points to a valid repo""" + + # Git will return a non-zero status code if it can't access the given URL. + status = subprocess.call( + ["git", "ls-remote", self.url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return status == 0 + + def revision_exists(self, revision: str) -> bool: + """Get whether the remote repo contains a commit with a given revision""" + + # If the given revision is a tag or branch, then ls-remote can find it. + # The output will be empty if the revision isn't found. + if subprocess.check_output(["git", "ls-remote", self.url, revision]): + return True + + # If the given revision is a (possibly abbreviated) commit hash, then + # check if it can be fetched from the remote repo without actually + # fetching it. (This works for commit hashes and tags, but not branches.) + status = subprocess.call( + [ + "git", + "fetch", + self.url, + revision, + "--negotiate-only", + "--negotiation-tip", + revision, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return status == 0 + + def get_tags(self) -> list[str]: + """ + Get a list of tags from the remote repo. + + Tags are sorted in descending order by version. + """ + lines = subprocess.check_output( + ["git", "ls-remote", "--tags", "--refs", self.url], text=True + ).splitlines() + + # ls-remote output is " refs/tags/" for each tag. + # Return only the text after "refs/tags/". + tags = (line.split()[-1].removeprefix("refs/tags/") for line in lines) + + return sorted( + tags, + key=_TaggedVersion, + reverse=True, + ) + + +class _RemoteImpl: + """Implementation for platform-specific accessors""" + + @property + def firmware_download_url(self) -> str: + """URL of a page where users can download firmware builds""" + raise NotImplementedError() + + +class _GitHubRemote(_RemoteImpl): + """Implementation for GitHub""" + + def __init__(self, parsed: giturlparse.GitUrlParsed): + self._parsed = parsed + + @property + def owner(self) -> str: + """Username of the repo's owner""" + return self._parsed.owner + + @property + def repo(self) -> str: + """Name of the repo""" + return self._parsed.repo + + @property + def firmware_download_url(self) -> str: + return ( + f"https://github.com/{self.owner}/{self.repo}/actions/workflows/build.yml" + ) + + +class _TaggedVersion: + major: int | None = None + minor: int | None = None + patch: int | None = None + + def __init__(self, tag: str): + self.tag = tag + + if m := re.match(r"v(\d+)(?:\.(\d+))?(?:\.(\d+))?", self.tag): + self.major = _try_int(m.group(1)) + self.minor = _try_int(m.group(2)) + self.patch = _try_int(m.group(3)) + + def __lt__(self, other: "_TaggedVersion"): + return self._sort_key < other._sort_key + + @property + def _sort_key(self): + return ( + _int_or_inf(self.major), + _int_or_inf(self.minor), + _int_or_inf(self.patch), + ) + + +def _try_int(val: str | None): + return None if val is None else int(val) + + +def _int_or_inf(val: int | None): + return math.inf if val is None else val diff --git a/zmk/repo.py b/zmk/repo.py index f607c30..821e1e6 100644 --- a/zmk/repo.py +++ b/zmk/repo.py @@ -9,7 +9,10 @@ from pathlib import Path from typing import Any, Literal, overload -from .yaml import read_yaml +import west.manifest + +from .remote import Remote +from .yaml import YAML, read_yaml _APP_DIR_NAME = "app" _BUILD_MATRIX_PATH = "build.yaml" @@ -121,6 +124,28 @@ def get_remote_url(self) -> str: remote = self.git("remote", capture_output=True).strip() return self.git("remote", "get-url", remote, capture_output=True).strip() + def get_remote(self) -> Remote: + """Get a Remote object for the checked out Git branch's remote URL.""" + return Remote(self.get_remote_url()) + + def get_west_manifest(self) -> west.manifest.Manifest: + """Return the parsed contents of the "west.yml" file.""" + return west.manifest.Manifest.from_topdir( + topdir=self.west_path, import_flags=west.manifest.ImportFlag.IGNORE + ) + + def get_west_zmk_project(self) -> west.manifest.Project: + """Return the West project for the "zmk" repo.""" + manifest = self.get_west_manifest() + projects = manifest.get_projects(["zmk"]) + + try: + return projects[0] + except IndexError as ex: + raise RuntimeError( + f'{self.project_manifest_path} is missing "zmk" project.' + ) from ex + @property def build_matrix_path(self) -> Path: """Path to the "build.yaml" file.""" @@ -201,6 +226,31 @@ def ensure_west_ready(self) -> None: self._west_ready = True + def set_zmk_version(self, revision: str) -> None: + """ + Modifies the "west.yml" file to change the revision for the "zmk" project. + + This does not automatically check out the new revision. Run + Repo.run_west("update") after calling this. + + :raises ValueError: if the given revision does not exist in the remote repo. + """ + zmk = self.get_west_zmk_project() + + remote = Remote(zmk.url) + if not remote.revision_exists(revision): + raise ValueError(f'Revision "{revision}" does not exist in {zmk.url}') + + yaml = YAML() + data = yaml.load(self.project_manifest_path) + + for project in data["manifest"]["projects"]: + if project["name"] == "zmk": + project["revision"] = revision + break + + yaml.dump(data, self.project_manifest_path) + @overload def _run_west(self, *args: str, capture_output: Literal[False] = False) -> None: ... From 196a9f25c4c7db45853878a2c6b7a779a9f4aa1a Mon Sep 17 00:00:00 2001 From: Joel Spadin Date: Sat, 10 Jan 2026 16:25:46 -0600 Subject: [PATCH 2/4] feat(init): Add options for URL, directory, and ZMK version The "zmk init" command now supports arguments for setting the URL and clone directory directly instead of prompting for them. It also now has a --zmk-version option, which switches to the given revision of ZMK prior to running "west update". If the selected revision does not exist, then it leaves the version unchanged. Finishes implementing #52 --- zmk/commands/init.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/zmk/commands/init.py b/zmk/commands/init.py index 18c8957..7e04c17 100644 --- a/zmk/commands/init.py +++ b/zmk/commands/init.py @@ -7,6 +7,7 @@ import subprocess import webbrowser from pathlib import Path +from typing import Annotated from urllib.parse import urlparse import rich @@ -29,7 +30,24 @@ TEXT_WIDTH = 80 -def init(ctx: typer.Context) -> None: +def init( + ctx: typer.Context, + url: Annotated[ + str | None, typer.Argument(help="URL of an existing repository to clone.") + ] = None, + name: Annotated[ + str | None, + typer.Argument(help="Directory name where the repo should be cloned."), + ] = None, + revision: Annotated[ + str | None, + typer.Option( + "--zmk-version", + metavar="REVISION", + help="Use the specified version of ZMK instead of the default.", + ), + ] = None, +) -> None: """Create a new ZMK config repo or clone an existing one.""" console = rich.get_console() @@ -38,12 +56,28 @@ def init(ctx: typer.Context) -> None: _check_dependencies() _check_for_existing_repo(cfg) - url = _get_repo_url() - name = _get_directory_name(url) + if url is None: + url = _get_repo_url() + + if name is None: + name = _get_directory_name(url) _clone_repo(url, name) repo = Repo(Path() / name) + + if revision: + try: + repo.ensure_west_ready() + repo.set_zmk_version(revision) + except ValueError as ex: + console.print() + console.print( + f'[yellow]Failed to switch to ZMK revision "{revision}":[/yellow]' + ) + console.print(ex) + console.print() + repo.run_west("update") console.print() From 86037702452c84033b92fe1ff633cfe1b81587f9 Mon Sep 17 00:00:00 2001 From: Joel Spadin Date: Sat, 10 Jan 2026 20:21:17 -0600 Subject: [PATCH 3/4] fix(yaml): Better preserve YAML formatting Updated the YAML editing code to work around an issue where blank lines and multi-line comments weren't handled correctly, resulting in blank lines getting deleted and new blank lines getting inserted in comments. Also changed the default indentation to match what is used in the ZMK config template, and enabled preserving quotes by default. --- zmk/yaml.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/zmk/yaml.py b/zmk/yaml.py index 35bf618..1a976e6 100644 --- a/zmk/yaml.py +++ b/zmk/yaml.py @@ -18,6 +18,22 @@ class YAML(ruamel.yaml.YAML): readable or seekable, a leading comment will be overwritten. """ + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.preserve_quotes = True + self.indent(mapping=2, sequence=4, offset=2) + + def load(self, stream: Path | IO | str) -> Any: + # Reading from a Path causes a round-trip conversion to drop blank lines + # for some reason. As a workaround, just read in the whole file to a + # string and parse that. It's probably slower, but none of the file we + # work with are large enough for it to matter. + if isinstance(stream, Path): + return super().load(stream.read_text()) + + return super().load(stream) + def dump(self, data, stream: Path | IO | None = None, *, transform=None) -> None: if stream is None: raise TypeError("Dumping from a context manager is not supported.") From bec7c2303d96cc8e9c12f5c4549f848512119d45 Mon Sep 17 00:00:00 2001 From: Joel Spadin Date: Sat, 10 Jan 2026 20:24:07 -0600 Subject: [PATCH 4/4] fix: Update ZMK version more thoroughly Provided the user is still using the default GitHub workflow, the "zmk version" command will now update that to point to the correct revision of ZMK as well. Also reworked the logic for updating the version in the West manifest to set manifest.defaults.revision instead of setting the revision on the ZMK project. This matches what is now done in the config template, which allows updating the version in one place for any modules that tag commits to match ZMK versions. --- zmk/commands/version.py | 2 +- zmk/repo.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/zmk/commands/version.py b/zmk/commands/version.py index 11f63c4..d1c98f8 100644 --- a/zmk/commands/version.py +++ b/zmk/commands/version.py @@ -70,7 +70,7 @@ def _print_current_version(repo: Repo): def _set_version(repo: Repo, revision: str): repo.set_zmk_version(revision) - repo.run_west("update", "zmk") + repo.run_west("update") rich.print() rich.print(f'ZMK is now using revision "{revision}"') diff --git a/zmk/repo.py b/zmk/repo.py index 821e1e6..3aa9a35 100644 --- a/zmk/repo.py +++ b/zmk/repo.py @@ -16,6 +16,7 @@ _APP_DIR_NAME = "app" _BUILD_MATRIX_PATH = "build.yaml" +_BUILD_WORKFLOW_PATH = ".github/workflows/build.yml" _CONFIG_DIR_NAME = "config" _PROJECT_MANIFEST_PATH = f"{_CONFIG_DIR_NAME}/west.yml" _MODULE_MANIFEST_PATH = "zephyr/module.yml" @@ -151,6 +152,11 @@ def build_matrix_path(self) -> Path: """Path to the "build.yaml" file.""" return self.path / _BUILD_MATRIX_PATH + @property + def build_workflow_path(self) -> Path: + """Path to the GitHub workflow build.yml file.""" + return self.path / _BUILD_WORKFLOW_PATH + @property def config_path(self) -> Path: """Path to the "config" folder.""" @@ -228,7 +234,8 @@ def ensure_west_ready(self) -> None: def set_zmk_version(self, revision: str) -> None: """ - Modifies the "west.yml" file to change the revision for the "zmk" project. + Modifies the "west.yml" file to change the default revision for projects + and modifies the GitHub workflow file to match. This does not automatically check out the new revision. Run Repo.run_west("update") after calling this. @@ -241,16 +248,42 @@ def set_zmk_version(self, revision: str) -> None: if not remote.revision_exists(revision): raise ValueError(f'Revision "{revision}" does not exist in {zmk.url}') + # Update the project manifest yaml = YAML() data = yaml.load(self.project_manifest_path) + if not "defaults" in data["manifest"]: + data["manifest"]["defaults"] = yaml.map() + + data["manifest"]["defaults"]["revision"] = revision + for project in data["manifest"]["projects"]: if project["name"] == "zmk": - project["revision"] = revision + try: + del project["revision"] + except KeyError: + pass break yaml.dump(data, self.project_manifest_path) + # Update the build workflow to match. The user may have customized this + # file, so only update it if it looks like it's using the default workflow, + # and ignore any errors to read or update it. + try: + yaml = YAML() + data = yaml.load(self.build_workflow_path) + + build = data["jobs"]["build"] + workflow, _, _ = build["uses"].rpartition("@") + + if workflow.endswith(".github/workflows/build-user-config.yml"): + build["uses"] = f"{workflow}@{revision}" + + yaml.dump(data, self.build_workflow_path) + except KeyError: + pass + @overload def _run_west(self, *args: str, capture_output: Literal[False] = False) -> None: ...